feat: 增强奖池与钱包管理功能

更新 AdminJackpotPoolUpdateController 校验规则,禁止传入 current_amount。
优化 AdminRiskPoolManualStatusController:更新奖池状态后同步 Redis 状态。
在 TransferOrderReconcileController 中新增 completeCredit 方法,用于处理卡住的转账订单对账。
调整 TransferOrderListController:优化转账订单处理条件。
在 TicketItemsIndexController 中实现支持时区的日期筛选,提升日期处理准确性。
扩展 JackpotPool 模型,新增 adjustments 关联关系。
改进票据与钱包相关服务中的错误处理和事务管理。
This commit is contained in:
2026-05-26 14:58:41 +08:00
parent 48349e3302
commit c8c90e3e94
45 changed files with 1877 additions and 104 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Jackpot;
use App\Models\JackpotPool;
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Jackpot\JackpotPoolAdjustmentService;
use App\Http\Requests\Admin\Jackpot\AdminJackpotPoolAdjustRequest;
use App\Http\Controllers\Api\V1\Admin\Jackpot\Concerns\PresentsJackpotPoolAdjustment;
/**
* POST /api/v1/admin/jackpot/pools/{pool}/adjustments 奖池余额调整(须备注,写流水)。
*/
final class AdminJackpotPoolAdjustController extends Controller
{
use PresentsJackpotPoolAdjustment;
public function __construct(
private readonly JackpotPoolAdjustmentService $adjustments,
) {}
public function __invoke(AdminJackpotPoolAdjustRequest $request, JackpotPool $pool): JsonResponse
{
$admin = $request->user();
if (! $admin instanceof AdminUser) {
return ApiResponse::error(
trans('admin.unauthenticated', [], $request->lotteryLocale()),
ErrorCode::AdminUnauthenticated->value,
null,
401,
);
}
try {
$row = $this->adjustments->apply(
$pool,
$admin,
(int) $request->validated('amount_delta'),
(string) $request->validated('reason'),
$request,
);
} catch (\RuntimeException $e) {
$msg = match ($e->getMessage()) {
'adjustment_delta_zero' => trans('jackpot.adjustment_delta_zero', [], $request->lotteryLocale()),
'adjustment_reason_required' => trans('jackpot.adjustment_reason_required', [], $request->lotteryLocale()),
'adjustment_would_make_balance_negative' => trans('jackpot.adjustment_negative_balance', [], $request->lotteryLocale()),
default => trans('api.client_error', [], $request->lotteryLocale()),
};
return ApiResponse::error($msg, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 422);
}
$pool->refresh();
return ApiResponse::success([
'adjustment' => $this->adjustmentRow($row),
'pool' => [
'id' => (int) $pool->id,
'currency_code' => $pool->currency_code,
'current_amount' => (int) $pool->current_amount,
'updated_at' => $pool->updated_at?->toIso8601String(),
],
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Jackpot;
use App\Models\JackpotPool;
use App\Support\ApiResponse;
use App\Models\JackpotPoolAdjustment;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\PaginationTrait;
use Illuminate\Http\Request;
use App\Http\Controllers\Api\V1\Admin\Jackpot\Concerns\PresentsJackpotPoolAdjustment;
/**
* GET /api/v1/admin/jackpot/pools/{pool}/adjustments 奖池余额调整流水。
*/
final class AdminJackpotPoolAdjustmentIndexController extends Controller
{
use PaginationTrait;
use PresentsJackpotPoolAdjustment;
public function __invoke(Request $request, JackpotPool $pool): JsonResponse
{
$perPage = $this->perPage($request, 'per_page', 10, 100);
$page = $this->page($request);
$paginator = JackpotPoolAdjustment::query()
->where('jackpot_pool_id', $pool->id)
->with('adminUser:id,username,name')
->orderByDesc('id')
->paginate($perPage, ['*'], 'page', $page);
return ApiResponse::success([
'items' => $paginator->getCollection()
->map(fn (JackpotPoolAdjustment $row) => $this->adjustmentRow($row))
->values()
->all(),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
]);
}
}

View File

@@ -16,7 +16,7 @@ final class AdminJackpotPoolUpdateController extends Controller
public function __invoke(Request $request, JackpotPool $pool): JsonResponse
{
$data = $request->validate([
'current_amount' => 'sometimes|integer|min:0',
'current_amount' => 'prohibited',
'contribution_rate' => 'sometimes|numeric|min:0|max:1',
'trigger_threshold' => 'sometimes|integer|min:0',
'payout_rate' => 'sometimes|numeric|min:0|max:1',

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Jackpot\Concerns;
use App\Models\JackpotPoolAdjustment;
trait PresentsJackpotPoolAdjustment
{
/** @return array<string, mixed> */
private function adjustmentRow(JackpotPoolAdjustment $row): array
{
return [
'id' => (int) $row->id,
'adjustment_no' => $row->adjustment_no,
'jackpot_pool_id' => (int) $row->jackpot_pool_id,
'admin_user_id' => (int) $row->admin_user_id,
'admin_username' => $row->adminUser?->username,
'amount_delta' => (int) $row->amount_delta,
'balance_before' => (int) $row->balance_before,
'balance_after' => (int) $row->balance_after,
'reason' => $row->reason,
'created_at' => $row->created_at?->toIso8601String(),
];
}
}

View File

@@ -10,9 +10,13 @@ use App\Models\RiskPoolLockLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Services\Ticket\RiskPoolService;
final class AdminRiskPoolManualStatusController extends Controller
{
public function __construct(
private readonly RiskPoolService $riskPoolService,
) {}
public function close(Draw $draw, string $number_4d): JsonResponse
{
$pool = $this->updateStatus($draw, $number_4d, true, 'close', 'admin_manual_close');
@@ -79,7 +83,12 @@ final class AdminRiskPoolManualStatusController extends Controller
]);
}
return $pool->fresh();
$fresh = $pool->fresh();
if ($fresh !== null) {
$this->riskPoolService->syncRedisStateFromPool($fresh);
}
return $fresh;
});
}

View File

@@ -122,7 +122,14 @@ final class TransferOrderListController extends Controller
'idempotent_key' => $o->idempotent_key,
'status' => $o->status,
'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile',
'can_manually_process' => $canWriteWallet && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true),
'can_complete_credit' => $canWriteWallet
&& $o->direction === 'in'
&& $o->status === 'pending_reconcile'
&& $o->fail_reason === 'lottery_credit_failed'
&& trim((string) $o->external_ref_no) !== '',
'can_manually_process' => $canWriteWallet
&& in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true)
&& ! ($o->direction === 'out' && $o->status === 'pending_reconcile'),
'external_ref_no' => $o->external_ref_no,
'external_request_payload' => $o->external_request_payload,
'external_response_payload' => $o->external_response_payload,

View File

@@ -10,6 +10,7 @@ use App\Services\Wallet\LotteryTransferService;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Wallet\TransferOrderReverseRequest;
use App\Http\Requests\Admin\Wallet\TransferOrderManuallyProcessRequest;
use App\Http\Requests\Admin\Wallet\TransferOrderCompleteCreditRequest;
use Illuminate\Http\JsonResponse;
/**
@@ -71,4 +72,29 @@ final class TransferOrderReconcileController extends Controller
return ApiResponse::success(['transfer_no' => $transferNo, 'status' => 'manually_processed']);
}
public function completeCredit(TransferOrderCompleteCreditRequest $request, string $transferNo): JsonResponse
{
$order = TransferOrder::query()->where('transfer_no', $transferNo)->first();
if ($order === null) {
return ApiResponse::error(__('wallet.order_not_found'), 404);
}
try {
$this->transferService->reconcileTransferOrder(
$order,
'complete_credit',
(string) $request->validated('remark', ''),
);
} catch (WalletOperationException $e) {
return ApiResponse::error(
LotteryMessage::wallet($request, $e->lotteryCode),
$e->lotteryCode,
null,
$e->httpStatus,
);
}
return ApiResponse::success(['transfer_no' => $transferNo, 'status' => 'success']);
}
}

View File

@@ -61,14 +61,14 @@ final class TicketDrawMyMatchController extends Controller
$itemIds = TicketItem::query()
->where('draw_id', $draw->id)
->where('player_id', $player->id)
->whereIn('status', ['pending_draw', 'settled_win', 'settled_lose'])
->whereIn('status', ['pending_draw', 'pending_payout', 'settled_win', 'settled_lose'])
->pluck('id');
$hasBets = $itemIds->isNotEmpty();
$winningItemIds = TicketItem::query()
->where('draw_id', $draw->id)
->where('player_id', $player->id)
->where('status', 'settled_win')
->whereIn('status', ['settled_win', 'pending_payout'])
->where(function ($q): void {
$q->where('win_amount', '>', 0)
->orWhere('jackpot_win_amount', '>', 0);
@@ -90,7 +90,7 @@ final class TicketDrawMyMatchController extends Controller
$sums = TicketItem::query()
->where('draw_id', $draw->id)
->where('player_id', $player->id)
->whereIn('status', ['settled_win', 'settled_lose'])
->whereIn('status', ['settled_win', 'settled_lose', 'pending_payout'])
->selectRaw('coalesce(sum(win_amount),0) as sum_win, coalesce(sum(jackpot_win_amount),0) as sum_jackpot')
->first();

View File

@@ -62,12 +62,13 @@ final class TicketItemShowController extends Controller
->where('biz_no', $order?->order_no)
->orderByDesc('id')
->first();
$payoutTxn = WalletTxn::query()
->where('player_id', $player->id)
->where('biz_type', 'settle_payout')
->where('biz_no', 'like', 'SB%')
->orderByDesc('id')
->first();
$payoutTxn = $settlementBatch !== null
? WalletTxn::query()
->where('player_id', $player->id)
->where('biz_type', 'settle_payout')
->where('biz_no', 'SB'.$settlementBatch->id)
->first()
: null;
$timeline = [];
if ($order?->created_at !== null) {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Controllers\Api\V1\Ticket;
use Carbon\Carbon;
use App\Models\Player;
use App\Models\TicketItem;
use App\Models\WalletTxn;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Support\PaginationTrait;
@@ -65,11 +65,13 @@ final class TicketItemsIndexController extends Controller
}
if ($startDate !== null) {
$query->whereHas('order', fn ($q) => $q->whereDate('created_at', '>=', $startDate));
$fromUtc = $this->scheduleDateStartUtc($startDate);
$query->whereHas('order', fn ($q) => $q->where('created_at', '>=', $fromUtc));
}
if ($endDate !== null) {
$query->whereHas('order', fn ($q) => $q->whereDate('created_at', '<=', $endDate));
$toUtc = $this->scheduleDateEndUtc($endDate);
$query->whereHas('order', fn ($q) => $q->where('created_at', '<=', $toUtc));
}
$paginator = $query->paginate(perPage: $perPage, page: $page);
@@ -119,4 +121,23 @@ final class TicketItemsIndexController extends Controller
return $value;
}
private function scheduleTimezone(): string
{
return (string) config('lottery.draw.timezone', 'UTC');
}
private function scheduleDateStartUtc(string $ymd): Carbon
{
return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone())
->startOfDay()
->utc();
}
private function scheduleDateEndUtc(string $ymd): Carbon
{
return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone())
->endOfDay()
->utc();
}
}

View File

@@ -27,8 +27,8 @@ final class WalletLogsController extends Controller
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'reversal' => ['reversal'],
'bet' => ['bet_deduct', 'bet'],
'prize' => ['settle_payout', 'prize'],
'bet' => ['bet_deduct', 'bet', 'bet_reverse'],
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
];
public function __invoke(Request $request): JsonResponse
@@ -153,8 +153,9 @@ final class WalletLogsController extends Controller
{
return match ($biz) {
'transfer_out_refund' => 'refund',
'bet_deduct' => 'bet',
'settle_payout' => 'prize',
'bet_deduct', 'bet' => 'bet',
'bet_reverse' => 'reversal',
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
'reversal' => 'reversal',
default => $biz,
};

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin\Jackpot;
use Illuminate\Foundation\Http\FormRequest;
final class AdminJackpotPoolAdjustRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'amount_delta' => ['required', 'integer', 'not_in:0'],
'reason' => ['required', 'string', 'min:3', 'max:500'],
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin\Wallet;
use Illuminate\Foundation\Http\FormRequest;
final class TransferOrderCompleteCreditRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'remark' => ['sometimes', 'nullable', 'string', 'max:255'],
];
}
}

View File

@@ -46,4 +46,9 @@ final class JackpotPool extends Model
{
return $this->hasMany(JackpotContribution::class, 'jackpot_pool_id');
}
public function adjustments(): HasMany
{
return $this->hasMany(JackpotPoolAdjustment::class, 'jackpot_pool_id');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** 奖池余额人工调整流水 {@see jackpot_pool_adjustments} */
final class JackpotPoolAdjustment extends Model
{
protected $fillable = [
'adjustment_no',
'jackpot_pool_id',
'admin_user_id',
'amount_delta',
'balance_before',
'balance_after',
'reason',
];
protected function casts(): array
{
return [
'amount_delta' => 'integer',
'balance_before' => 'integer',
'balance_after' => 'integer',
];
}
/** @return BelongsTo<JackpotPool, JackpotPoolAdjustment> */
public function pool(): BelongsTo
{
return $this->belongsTo(JackpotPool::class, 'jackpot_pool_id');
}
/** @return BelongsTo<AdminUser, JackpotPoolAdjustment> */
public function adminUser(): BelongsTo
{
return $this->belongsTo(AdminUser::class, 'admin_user_id');
}
}

View File

@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\DB;
final class DrawAdminActionService
{
public function __construct(
private readonly DrawCancelBetRefundService $cancelBetRefund,
) {}
public function manualClose(Draw $draw): Draw
{
return DB::transaction(function () use ($draw): Draw {
@@ -43,6 +47,8 @@ final class DrawAdminActionService
throw new \RuntimeException('draw_result_exists');
}
$this->cancelBetRefund->refundOpenBetsForDraw($locked);
$locked->forceFill(['status' => DrawStatus::Cancelled->value])->save();
return $locked->refresh();

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Services\Draw;
use App\Models\Draw;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\WalletTxn;
use App\Services\Ticket\RiskPoolService;
use App\Services\Ticket\TicketWalletService;
/**
* 取消期号前:退本、释池,避免已扣款注单悬空。
*/
final class DrawCancelBetRefundService
{
public function __construct(
private readonly RiskPoolService $riskPool,
private readonly TicketWalletService $ticketWallet,
) {}
public function refundOpenBetsForDraw(Draw $draw): void
{
$hasBlockedItems = TicketItem::query()
->where('draw_id', $draw->id)
->whereIn('status', ['settled_win', 'settled_lose', 'pending_payout'])
->exists();
if ($hasBlockedItems) {
throw new \RuntimeException('draw_has_settled_tickets');
}
$orders = TicketOrder::query()
->where('draw_id', $draw->id)
->whereNotIn('status', ['refunded', 'failed'])
->orderBy('id')
->get();
foreach ($orders as $order) {
$this->refundOrder($draw, $order);
}
}
private function refundOrder(Draw $draw, TicketOrder $order): void
{
$lockedOrder = TicketOrder::query()->whereKey($order->id)->lockForUpdate()->first();
if ($lockedOrder === null || in_array($lockedOrder->status, ['refunded', 'failed'], true)) {
return;
}
$items = TicketItem::query()
->where('order_id', $lockedOrder->id)
->whereIn('status', ['pending_confirm', 'pending_draw', 'pending_payout'])
->with('combinations')
->lockForUpdate()
->get();
foreach ($items as $item) {
$locks = [];
foreach ($item->combinations as $combo) {
$locks[] = [
'number_4d' => (string) $combo->number_4d,
'amount' => (int) $combo->estimated_payout,
];
}
if ($locks !== []) {
$this->riskPool->release((int) $draw->id, $item, $locks);
}
$item->forceFill([
'status' => 'refunded',
'fail_reason_code' => 'draw_cancelled',
'fail_reason_text' => 'draw_cancelled_refund',
'risk_locked_amount' => 0,
])->save();
}
$hasPostedDeduct = WalletTxn::query()
->where('biz_type', 'bet_deduct')
->where('biz_no', $lockedOrder->order_no)
->where('status', 'posted')
->exists();
if ($hasPostedDeduct) {
$this->ticketWallet->reverseBetDeduct($lockedOrder);
}
$lockedOrder->forceFill(['status' => 'refunded'])->save();
}
}

View File

@@ -60,6 +60,14 @@ final class DrawHallSnapshotBuilder
return DrawStatus::Closing->value;
}
/** 与大厅 {@see effectiveHallDisplayStatus} 一致:是否仍接受 preview/place。 */
public function isBettingOpen(Draw $draw, ?Carbon $nowUtc = null): bool
{
$nowUtc ??= now()->utc();
return $this->effectiveHallDisplayStatus($draw, $nowUtc) === DrawStatus::Open->value;
}
private function showsPublishedResults(string $drawStatus): bool
{
return in_array($drawStatus, [

View File

@@ -28,6 +28,13 @@ final class DrawManualResultService
throw new \RuntimeException('draw_already_settled');
}
if (DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::PendingReview->value)
->exists()) {
throw new \RuntimeException('draw_pending_result_batch_exists');
}
$nextVersion = max(1, (int) $locked->current_result_version + 1);
$batch = DrawResultBatch::query()->create([
'draw_id' => $locked->id,

View File

@@ -6,9 +6,11 @@ use App\Models\Draw;
use App\Models\AdminUser;
use App\Lottery\DrawStatus;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use Illuminate\Support\Facades\DB;
use App\Services\LotterySettings;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
/**
* 人工审核通过后发布结果;或 RNG 自动生成路径内联调用同一事务字段更新。
@@ -114,10 +116,28 @@ final class DrawPublishService
DrawStatus::Closed->value,
DrawStatus::Review->value,
DrawStatus::Cooldown->value,
DrawStatus::Settling->value,
];
if (! in_array($draw->status, $allowed, true)) {
throw new \RuntimeException('draw_not_ready_to_publish');
}
$this->assertNoActiveSettlementWorkflow($draw);
}
/** 存在未完结结算批次时不允许改发布结果,避免派彩与大厅展示号码不一致。 */
private function assertNoActiveSettlementWorkflow(Draw $draw): void
{
$active = SettlementBatch::query()
->where('draw_id', $draw->id)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
])
->exists();
if ($active) {
throw new \RuntimeException('draw_settlement_in_progress');
}
}
}

View File

@@ -35,6 +35,14 @@ final class JackpotContributionService
return;
}
$existing = JackpotContribution::query()
->where('ticket_item_id', $item->id)
->first();
if ($existing !== null) {
return;
}
JackpotContribution::query()->create([
'jackpot_pool_id' => $pool->id,
'draw_id' => $draw->id,

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Services\Jackpot;
use App\Models\AdminUser;
use App\Models\JackpotPool;
use App\Models\JackpotPoolAdjustment;
use App\Services\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class JackpotPoolAdjustmentService
{
public function apply(
JackpotPool $pool,
AdminUser $admin,
int $amountDelta,
string $reason,
?Request $request = null,
): JackpotPoolAdjustment {
if ($amountDelta === 0) {
throw new \RuntimeException('adjustment_delta_zero');
}
$reason = trim($reason);
if ($reason === '') {
throw new \RuntimeException('adjustment_reason_required');
}
return DB::transaction(function () use ($pool, $admin, $amountDelta, $reason, $request): JackpotPoolAdjustment {
/** @var JackpotPool $locked */
$locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail();
$before = (int) $locked->current_amount;
$after = $before + $amountDelta;
if ($after < 0) {
throw new \RuntimeException('adjustment_would_make_balance_negative');
}
$adjustment = JackpotPoolAdjustment::query()->create([
'adjustment_no' => $this->newAdjustmentNo(),
'jackpot_pool_id' => $locked->id,
'admin_user_id' => $admin->id,
'amount_delta' => $amountDelta,
'balance_before' => $before,
'balance_after' => $after,
'reason' => $reason,
]);
$locked->forceFill(['current_amount' => $after])->save();
$snapshot = [
'currency_code' => $locked->currency_code,
'amount_delta' => $amountDelta,
'balance_before' => $before,
'balance_after' => $after,
'reason' => $reason,
'adjustment_no' => $adjustment->adjustment_no,
];
if ($request !== null) {
AuditLogger::recordFromRequest(
$request,
AuditLogger::OPERATOR_ADMIN,
$admin->id,
'jackpot',
'adjust_balance',
'jackpot_pool',
(string) $locked->id,
['current_amount' => $before],
$snapshot,
);
} else {
AuditLogger::record(
AuditLogger::OPERATOR_ADMIN,
$admin->id,
'jackpot',
'adjust_balance',
'jackpot_pool',
(string) $locked->id,
['current_amount' => $before],
$snapshot,
);
}
return $adjustment->fresh(['adminUser']);
});
}
private function newAdjustmentNo(): string
{
return 'JA'.now()->format('YmdHis').Str::upper(Str::random(6));
}
}

View File

@@ -97,11 +97,15 @@ final class SettlementBatchWorkflowService
$batchItemIds = $locked->details()->pluck('ticket_item_id')->map(fn ($id) => (int) $id)->all();
$hasPendingDraw = TicketItem::query()
$hasUnsettled = TicketItem::query()
->where('draw_id', $locked->draw_id)
->where('status', 'pending_draw')
->whereIn('status', [
'pending_confirm',
'partial_pending_confirm',
'pending_draw',
])
->exists();
if ($hasPendingDraw) {
if ($hasUnsettled) {
throw new \RuntimeException('draw_has_unsettled_tickets');
}

View File

@@ -66,6 +66,7 @@ final class SettlementOrchestrator
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,

View File

@@ -172,6 +172,28 @@ final class RiskPoolService
}
}
/** 后台改池或释池后,将 Redis 风控快照与 DB 对齐。 */
public function syncRedisStateFromPool(RiskPool $pool): void
{
if (! $this->shouldUseRedisAtomicLocks()) {
return;
}
$total = (int) $pool->total_cap_amount;
$locked = (int) $pool->locked_amount;
$remaining = max(0, $total - $locked);
Redis::eval(
$this->overwriteStateLua(),
1,
$this->redisPoolKey((int) $pool->draw_id, (string) $pool->normalized_number),
$total,
$locked,
$remaining,
(int) $pool->version,
);
}
private function shouldUseRedisAtomicLocks(): bool
{
if (App::environment('testing')) {
@@ -196,6 +218,14 @@ return 1
LUA;
}
private function overwriteStateLua(): string
{
return <<<'LUA'
redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[3], 'version', ARGV[4])
return 1
LUA;
}
private function acquireLua(): string
{
return <<<'LUA'

View File

@@ -2,15 +2,21 @@
namespace App\Services\Ticket;
use App\Models\Draw;
use App\Models\WalletTxn;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Jackpot\JackpotContributionService;
use Illuminate\Support\Facades\DB;
final class TicketPendingConfirmReconcileService
{
public function __construct(
private readonly RiskPoolService $riskPool,
private readonly JackpotContributionService $jackpotContribution,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
private readonly TicketWalletService $ticketWallet,
) {}
/**
@@ -20,7 +26,7 @@ final class TicketPendingConfirmReconcileService
{
$cutoff = now()->subMinutes($staleMinutes);
$orders = TicketOrder::query()
->where('status', 'pending_confirm')
->whereIn('status', ['pending_confirm', 'partial_pending_confirm'])
->where('updated_at', '<=', $cutoff)
->orderBy('id')
->limit($limit)
@@ -35,7 +41,7 @@ final class TicketPendingConfirmReconcileService
->lockForUpdate()
->first();
if ($lockedOrder === null || $lockedOrder->status !== 'pending_confirm') {
if ($lockedOrder === null || ! in_array($lockedOrder->status, ['pending_confirm', 'partial_pending_confirm'], true)) {
return 'skipped';
}
@@ -46,52 +52,10 @@ final class TicketPendingConfirmReconcileService
->exists();
if ($hasPostedDeduct) {
TicketItem::query()
->where('order_id', $lockedOrder->id)
->where('status', 'pending_confirm')
->update([
'status' => 'pending_draw',
'fail_reason_code' => null,
'fail_reason_text' => null,
'updated_at' => now(),
]);
$lockedOrder->forceFill(['status' => 'placed'])->save();
return 'confirmed';
return $this->confirmOrder($lockedOrder);
}
$items = TicketItem::query()
->where('order_id', $lockedOrder->id)
->where('status', 'pending_confirm')
->with('combinations')
->lockForUpdate()
->get();
foreach ($items as $item) {
$locks = [];
foreach ($item->combinations as $combo) {
$locks[] = [
'number_4d' => (string) $combo->number_4d,
'amount' => (int) $combo->estimated_payout,
];
}
if ($locks !== []) {
$this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks);
}
$item->forceFill([
'status' => 'refunded',
'fail_reason_code' => 'pending_confirm_timeout',
'fail_reason_text' => 'pending_confirm_timeout_refund',
'risk_locked_amount' => 0,
])->save();
}
$lockedOrder->forceFill(['status' => 'refunded'])->save();
return 'refunded';
return $this->refundOrderWithoutDeduct($lockedOrder);
});
if ($result === 'skipped') {
@@ -109,4 +73,104 @@ final class TicketPendingConfirmReconcileService
return $summary;
}
private function confirmOrder(TicketOrder $lockedOrder): string
{
$draw = Draw::query()->whereKey($lockedOrder->draw_id)->first();
if ($draw === null || ! $this->drawHallSnapshot->isBettingOpen($draw)) {
return $this->refundStalePendingOrder(
$lockedOrder,
$draw === null ? 'draw_missing' : 'draw_no_longer_open',
);
}
$items = TicketItem::query()
->where('order_id', $lockedOrder->id)
->where('status', 'pending_confirm')
->lockForUpdate()
->get();
foreach ($items as $item) {
$item->forceFill([
'status' => 'pending_draw',
'fail_reason_code' => null,
'fail_reason_text' => null,
])->save();
$this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, (string) $lockedOrder->currency_code);
}
$hasFailures = TicketItem::query()
->where('order_id', $lockedOrder->id)
->where('status', 'failed')
->exists();
$lockedOrder->forceFill([
'status' => $hasFailures ? 'partial_failed' : 'placed',
])->save();
return 'confirmed';
}
private function refundStalePendingOrder(TicketOrder $lockedOrder, string $reasonCode): string
{
$hasPostedDeduct = WalletTxn::query()
->where('biz_type', 'bet_deduct')
->where('biz_no', $lockedOrder->order_no)
->where('status', 'posted')
->exists();
if ($hasPostedDeduct) {
$this->ticketWallet->reverseBetDeduct($lockedOrder);
}
return $this->refundPendingConfirmItems($lockedOrder, $reasonCode);
}
private function refundOrderWithoutDeduct(TicketOrder $lockedOrder): string
{
return $this->refundPendingConfirmItems($lockedOrder, 'pending_confirm_timeout');
}
private function refundPendingConfirmItems(TicketOrder $lockedOrder, string $reasonCode): string
{
$items = TicketItem::query()
->where('order_id', $lockedOrder->id)
->where('status', 'pending_confirm')
->with('combinations')
->lockForUpdate()
->get();
foreach ($items as $item) {
$locks = [];
foreach ($item->combinations as $combo) {
$locks[] = [
'number_4d' => (string) $combo->number_4d,
'amount' => (int) $combo->estimated_payout,
];
}
if ($locks !== []) {
$this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks);
}
$item->forceFill([
'status' => 'refunded',
'fail_reason_code' => $reasonCode,
'fail_reason_text' => $reasonCode.'_refund',
'risk_locked_amount' => 0,
])->save();
}
$hasFailures = TicketItem::query()
->where('order_id', $lockedOrder->id)
->where('status', 'failed')
->exists();
$lockedOrder->forceFill([
'status' => $hasFailures ? 'partial_failed' : 'refunded',
])->save();
return 'refunded';
}
}

View File

@@ -7,13 +7,13 @@ use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Models\TicketItem;
use App\Models\WalletTxn;
use App\Lottery\DrawStatus;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\TicketCombination;
use Illuminate\Support\Facades\DB;
use App\Exceptions\TicketOperationException;
use App\Services\Jackpot\JackpotContributionService;
use App\Services\Draw\DrawHallSnapshotBuilder;
final class TicketPlacementService
{
@@ -23,6 +23,7 @@ final class TicketPlacementService
private readonly RiskPoolService $riskPoolService,
private readonly TicketWalletService $ticketWalletService,
private readonly JackpotContributionService $jackpotContribution,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
) {}
/**
@@ -36,11 +37,14 @@ final class TicketPlacementService
? (string) $payload['client_trace_id']
: null;
if ($clientTraceId !== null) {
$drawNo = (string) $payload['draw_id'];
$drawIdForIdempotency = Draw::query()->where('draw_no', $drawNo)->value('id');
if ($clientTraceId !== null && $drawIdForIdempotency !== null) {
$existing = TicketOrder::query()
->where('player_id', $player->id)
->where('draw_id', $drawIdForIdempotency)
->where('client_trace_id', $clientTraceId)
->whereIn('status', ['placed', 'partial_failed'])
->first();
if ($existing !== null) {
@@ -72,7 +76,7 @@ final class TicketPlacementService
if ($draw === null) {
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
}
if ($draw->status !== DrawStatus::Open->value || ($draw->close_time !== null && now()->greaterThanOrEqualTo($draw->close_time))) {
if (! $this->drawHallSnapshot->isBettingOpen($draw)) {
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
}
@@ -132,13 +136,15 @@ final class TicketPlacementService
);
}
$walletBalance = (int) (PlayerWallet::query()
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
->where('wallet_type', 'lottery')
->where('currency_code', $currencyCode)
->lockForUpdate()
->value('balance') ?? 0);
if ($walletBalance < $totalActualDeduct) {
->first();
$walletBalance = $wallet !== null ? (int) $wallet->balance : 0;
$walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0);
if ($walletAvailable < $totalActualDeduct) {
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
}
@@ -333,6 +339,10 @@ final class TicketPlacementService
$order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
$draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail();
$successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'pending_draw')->count();
$pendingConfirmCount = TicketItem::query()
->where('order_id', $order->id)
->whereIn('status', ['pending_confirm', 'partial_pending_confirm'])
->count();
$failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count();
if ($balanceAfter === null) {
$walletTxn = WalletTxn::query()
@@ -350,11 +360,13 @@ final class TicketPlacementService
'status' => $draw->status,
],
'summary' => [
'order_status' => $order->status,
'total_bet_amount' => (int) $order->total_bet_amount,
'total_rebate_amount' => (int) $order->total_rebate_amount,
'total_actual_deduct' => (int) $order->total_actual_deduct,
'total_estimated_payout' => (int) $order->total_estimated_payout,
'success_count' => $successCount,
'pending_confirm_count' => $pendingConfirmCount,
'failure_count' => $failureCount,
],
'balance_after' => $balanceAfter,

View File

@@ -4,8 +4,8 @@ namespace App\Services\Ticket;
use App\Models\Draw;
use App\Lottery\ErrorCode;
use App\Lottery\DrawStatus;
use App\Exceptions\TicketOperationException;
use App\Services\Draw\DrawHallSnapshotBuilder;
final class TicketPreviewService
{
@@ -13,6 +13,7 @@ final class TicketPreviewService
private readonly PlayCatalogResolver $catalogResolver,
private readonly PlayRuleEngine $ruleEngine,
private readonly RiskPoolService $riskPoolService,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
) {}
/**
@@ -25,7 +26,7 @@ final class TicketPreviewService
if ($draw === null) {
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
}
if ($draw->status !== DrawStatus::Open->value || ($draw->close_time !== null && now()->greaterThanOrEqualTo($draw->close_time))) {
if (! $this->drawHallSnapshot->isBettingOpen($draw)) {
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
}
@@ -108,10 +109,13 @@ final class TicketPreviewService
);
}
$nowUtc = now()->utc();
return [
'draw' => [
'draw_id' => $draw->draw_no,
'status' => $draw->status,
'status' => $this->drawHallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc),
'db_status' => $draw->status,
],
'config_versions' => $this->catalogResolver->currentActiveVersionStamp(),
'summary' => [

View File

@@ -39,8 +39,13 @@ final class TicketWalletService
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
}
if ((int) $wallet->status !== 0) {
throw new TicketOperationException('wallet_frozen', ErrorCode::WalletExternalRejected->value);
}
$before = (int) $wallet->balance;
if ($before < $amountMinor) {
$available = $before - (int) $wallet->frozen_balance;
if ($available < $amountMinor) {
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
}
@@ -62,7 +67,7 @@ final class TicketWalletService
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => $order->client_trace_id,
'idempotent_key' => 'bet_deduct:'.$order->order_no,
'remark' => null,
]);
@@ -191,6 +196,10 @@ final class TicketWalletService
}
$currency = strtoupper($currencyCode);
$idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id;
if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->where('biz_type', 'settle_payout')->exists()) {
return;
}
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
@@ -231,7 +240,7 @@ final class TicketWalletService
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => 'settle-payout:'.$settlementBatchId.':'.$player->id,
'idempotent_key' => $idempotentKey,
'remark' => null,
]);
}

View File

@@ -135,28 +135,44 @@ final class LotteryTransferService
);
}
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void {
$wallet = $this->lockLotteryWallet($player, $currencyCode);
$this->postLotteryWalletMovement(
wallet: $wallet,
bizType: self::BIZ_TRANSFER_IN,
direction: self::TXN_DIR_IN,
amountMinor: $amountMinor,
bizNo: $transferNo,
externalRefNo: $main->externalRefNo,
idempotentKey: $idempotentKey,
remark: null,
deltaSign: 1,
);
try {
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void {
$wallet = $this->lockLotteryWallet($player, $currencyCode);
$this->postLotteryWalletMovement(
wallet: $wallet,
bizType: self::BIZ_TRANSFER_IN,
direction: self::TXN_DIR_IN,
amountMinor: $amountMinor,
bizNo: $transferNo,
externalRefNo: $main->externalRefNo,
idempotentKey: $idempotentKey,
remark: null,
deltaSign: 1,
);
$order->forceFill([
'status' => self::ST_SUCCESS,
'external_ref_no' => $main->externalRefNo,
'external_request_payload' => $main->requestPayload,
'external_response_payload' => $main->responsePayload,
'finished_at' => now(),
])->save();
});
$order->forceFill([
'status' => self::ST_SUCCESS,
'external_ref_no' => $main->externalRefNo,
'external_request_payload' => $main->requestPayload,
'external_response_payload' => $main->responsePayload,
'finished_at' => now(),
])->save();
});
} catch (\Throwable $e) {
$order->refresh();
if ($order->status === self::ST_PROCESSING) {
$order->forceFill([
'status' => self::ST_PENDING_RECONCILE,
'external_ref_no' => $main->externalRefNo,
'external_request_payload' => $main->requestPayload,
'external_response_payload' => $main->responsePayload,
'fail_reason' => 'lottery_credit_failed',
'finished_at' => null,
])->save();
}
throw $e;
}
return $this->successPayload(
TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(),
@@ -328,6 +344,12 @@ final class LotteryTransferService
return;
}
if ($action === 'complete_credit') {
DB::transaction(fn (): mixed => $this->completeStuckTransferInCredit($order, $remark));
return;
}
if ($action === 'manually_process') {
DB::transaction(fn (): mixed => $this->doManuallyProcess($order, $remark));
@@ -388,6 +410,79 @@ final class LotteryTransferService
])->save();
}
/**
* 主站已扣款但彩票侧入账失败时,人工/对账补完成转入。
*/
private function completeStuckTransferInCredit(TransferOrder $order, string $remark): void
{
/** @var TransferOrder $locked */
$locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail();
if ($locked->direction !== self::DIR_IN) {
throw new WalletOperationException(
'invalid_reconcile_action',
ErrorCode::WalletExternalRejected->value,
422,
);
}
if ($locked->status === self::ST_SUCCESS) {
return;
}
if ($locked->status !== self::ST_PENDING_RECONCILE) {
throw new WalletOperationException(
'order_not_pending_reconcile',
ErrorCode::WalletExternalRejected->value,
422,
);
}
if (! $this->isEligibleForCompleteCredit($locked)) {
throw new WalletOperationException(
'complete_credit_not_eligible',
ErrorCode::WalletExternalRejected->value,
422,
);
}
$idempotentKey = (string) $locked->idempotent_key;
if (WalletTxn::query()
->where('idempotent_key', $idempotentKey)
->where('biz_type', self::BIZ_TRANSFER_IN)
->where('status', self::TXN_POSTED)
->exists()) {
$locked->forceFill([
'status' => self::ST_SUCCESS,
'finished_at' => now(),
])->save();
return;
}
$player = Player::query()->whereKey($locked->player_id)->firstOrFail();
$currencyCode = (string) $locked->currency_code;
$wallet = $this->lockLotteryWallet($player, $currencyCode);
$this->postLotteryWalletMovement(
wallet: $wallet,
bizType: self::BIZ_TRANSFER_IN,
direction: self::TXN_DIR_IN,
amountMinor: (int) $locked->amount,
bizNo: $locked->transfer_no,
externalRefNo: $locked->external_ref_no,
idempotentKey: $idempotentKey,
remark: $remark ?: 'complete_stuck_transfer_in',
deltaSign: 1,
);
$locked->forceFill([
'status' => self::ST_SUCCESS,
'fail_reason' => null,
'finished_at' => now(),
])->save();
}
private function doManuallyProcess(TransferOrder $order, string $remark): void
{
/** @var TransferOrder $locked */
@@ -406,6 +501,14 @@ final class LotteryTransferService
return;
}
if ($locked->direction === self::DIR_OUT && $locked->status === self::ST_PENDING_RECONCILE) {
throw new WalletOperationException(
'manually_process_requires_reverse_for_transfer_out',
ErrorCode::WalletExternalRejected->value,
422,
);
}
$locked->forceFill([
'status' => self::ST_MANUALLY_PROCESSED,
'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'),
@@ -413,6 +516,13 @@ final class LotteryTransferService
])->save();
}
/** 仅主站已扣款(有 external_ref_no且彩票入账失败时可补完成转入。 */
private function isEligibleForCompleteCredit(TransferOrder $order): bool
{
return $order->fail_reason === 'lottery_credit_failed'
&& trim((string) $order->external_ref_no) !== '';
}
private function lockLotteryWalletById(int $playerId, string $currencyCode): PlayerWallet
{
$wallet = PlayerWallet::query()
@@ -699,7 +809,8 @@ final class LotteryTransferService
bool $requireBalance = false,
): array {
$before = (int) $wallet->balance;
if ($requireBalance && $deltaSign < 0 && $before < $amountMinor) {
$available = $before - (int) $wallet->frozen_balance;
if ($requireBalance && $deltaSign < 0 && $available < $amountMinor) {
throw new WalletOperationException(
'insufficient_balance',
ErrorCode::WalletInsufficientBalance->value,

View File

@@ -421,6 +421,8 @@ final class AdminAuthorizationRegistry
['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['code' => 'admin.jackpot.contributions.index', 'module_code' => 'jackpot', 'name' => '奖池注入记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/contributions', 'route_name' => 'api.v1.admin.jackpot.contributions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['code' => 'admin.jackpot.pools.update', 'module_code' => 'jackpot', 'name' => '更新奖池', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}', 'route_name' => 'api.v1.admin.jackpot.pools.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']],
['code' => 'admin.jackpot.pools.adjustments.index', 'module_code' => 'jackpot', 'name' => '奖池调整流水', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/adjustments', 'route_name' => 'api.v1.admin.jackpot.pools.adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['code' => 'admin.jackpot.pools.adjustments.store', 'module_code' => 'jackpot', 'name' => '奖池余额调整', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/adjustments', 'route_name' => 'api.v1.admin.jackpot.pools.adjustments.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']],
['code' => 'admin.jackpot.pools.manual-burst', 'module_code' => 'jackpot', 'name' => '手动爆池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/manual-burst', 'route_name' => 'api.v1.admin.jackpot.pools.manual-burst', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manual_burst']],
['code' => 'admin.players.index', 'module_code' => 'player_service', 'name' => '玩家列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']],
@@ -438,6 +440,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.wallet.transactions', 'module_code' => 'wallet', 'name' => '钱包流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transactions', 'route_name' => 'api.v1.admin.wallet.transactions', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
['code' => 'admin.wallet.transfer-orders.complete-credit', 'module_code' => 'wallet', 'name' => '补完成转入入账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/complete-credit', 'route_name' => 'api.v1.admin.wallet.transfer-orders.complete-credit', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
['code' => 'admin.reconcile-jobs.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.reconcile-jobs.show', 'module_code' => 'reconcile', 'name' => '对账任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}', 'route_name' => 'api.v1.admin.reconcile-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$duplicateIds = DB::table('jackpot_contributions')
->select('ticket_item_id')
->whereNotNull('ticket_item_id')
->groupBy('ticket_item_id')
->havingRaw('count(*) > 1')
->pluck('ticket_item_id');
foreach ($duplicateIds as $ticketItemId) {
$rows = DB::table('jackpot_contributions')
->where('ticket_item_id', $ticketItemId)
->orderByDesc('id')
->pluck('id');
$keep = $rows->shift();
if ($keep !== null && $rows->isNotEmpty()) {
DB::table('jackpot_contributions')->whereIn('id', $rows->all())->delete();
}
}
Schema::table('jackpot_contributions', function (Blueprint $table): void {
$table->unique('ticket_item_id', 'uk_jackpot_contributions_ticket_item');
});
}
public function down(): void
{
Schema::table('jackpot_contributions', function (Blueprint $table): void {
$table->dropUnique('uk_jackpot_contributions_ticket_item');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Schema::create('jackpot_pool_adjustments', function (Blueprint $table): void {
$table->id();
$table->string('adjustment_no', 32)->unique();
$table->foreignId('jackpot_pool_id')->constrained('jackpot_pools')->cascadeOnDelete();
$table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete();
$table->bigInteger('amount_delta')->comment('signed minor units; + increase pool');
$table->bigInteger('balance_before');
$table->bigInteger('balance_after');
$table->string('reason', 500);
$table->timestamps();
$table->index(['jackpot_pool_id', 'created_at'], 'idx_jackpot_pool_adjustments_pool_created');
});
}
public function down(): void
{
Schema::dropIfExists('jackpot_pool_adjustments');
}
};

View File

@@ -0,0 +1,105 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** @var list<string> */
private const RESOURCE_CODES = [
'admin.jackpot.pools.adjustments.index',
'admin.jackpot.pools.adjustments.store',
];
public function up(): void
{
$now = Carbon::now();
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$resources = collect(AdminAuthorizationRegistry::resources())
->filter(fn (array $item): bool => in_array($item['code'], self::RESOURCE_CODES, true))
->values();
foreach ($resources as $resource) {
$resourceId = DB::table('admin_api_resources')
->where('code', $resource['code'])
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => $resource['code'],
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
->where('arb.api_resource_id', (int) $resourceId)
->select('rma.role_id')
->distinct()
->get();
if (Schema::hasTable('admin_role_api_resources')) {
foreach ($roleResourceRows as $row) {
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $row->role_id,
'api_resource_id' => (int) $resourceId,
], []);
}
}
}
}
public function down(): void
{
foreach (self::RESOURCE_CODES as $code) {
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
if ($resourceId === null) {
continue;
}
if (Schema::hasTable('admin_role_api_resources')) {
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
}
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
}
};

7
lang/en/jackpot.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
return [
'adjustment_delta_zero' => 'Adjustment amount cannot be zero',
'adjustment_reason_required' => 'Adjustment reason is required',
'adjustment_negative_balance' => 'Pool balance cannot be negative after adjustment',
];

7
lang/ne/jackpot.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
return [
'adjustment_delta_zero' => 'समायोजन रकम शून्य हुन सक्दैन',
'adjustment_reason_required' => 'समायोजन कारण अनिवार्य छ',
'adjustment_negative_balance' => 'समायोजन पछि पूल ब्यालेन्स ऋणात्मक हुन सक्दैन',
];

7
lang/zh/jackpot.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
return [
'adjustment_delta_zero' => '调整金额不能为 0',
'adjustment_reason_required' => '请填写调整原因',
'adjustment_negative_balance' => '调整后奖池余额不能为负数',
];

View File

@@ -3,9 +3,11 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolIndexController;
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolUpdateController;
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolAdjustController;
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolManualBurstController;
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPayoutLogIndexController;
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotContributionIndexController;
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolAdjustmentIndexController;
/**
* 管理员奖池管理路由。
@@ -20,6 +22,8 @@ Route::middleware('admin.api-resource')
->name('api.v1.admin.jackpot.payout-logs.index');
Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class)
->name('api.v1.admin.jackpot.contributions.index');
Route::get('jackpot/pools/{pool}/adjustments', AdminJackpotPoolAdjustmentIndexController::class)
->name('api.v1.admin.jackpot.pools.adjustments.index');
});
// 奖池修改(仅管理权限)
@@ -27,6 +31,8 @@ Route::middleware('admin.api-resource')
->group(function (): void {
Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)
->name('api.v1.admin.jackpot.pools.update');
Route::post('jackpot/pools/{pool}/adjustments', AdminJackpotPoolAdjustController::class)
->name('api.v1.admin.jackpot.pools.adjustments.store');
Route::post('jackpot/pools/{pool}/manual-burst', AdminJackpotPoolManualBurstController::class)
->name('api.v1.admin.jackpot.pools.manual-burst');
});

View File

@@ -36,6 +36,8 @@ Route::middleware('admin.api-resource')
->name('api.v1.admin.wallet.transfer-orders.reverse');
Route::post('wallet/transfer-orders/{transfer_no}/manually-process', [TransferOrderReconcileController::class, 'manuallyProcess'])
->name('api.v1.admin.wallet.transfer-orders.manually-process');
Route::post('wallet/transfer-orders/{transfer_no}/complete-credit', [TransferOrderReconcileController::class, 'completeCredit'])
->name('api.v1.admin.wallet.transfer-orders.complete-credit');
});
// 对账任务创建(仅管理权限)

View File

@@ -0,0 +1,243 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Lottery\DrawStatus;
use App\Models\PlayerWallet;
use App\Models\TransferOrder;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use App\Models\SettlementBatch;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function guardsAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'guards_admin_'.uniqid(),
'name' => 'Guards Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('cannot publish result batch while settlement batch is pending review', function (): void {
$token = guardsAdminToken();
$draw = Draw::query()->create([
'draw_no' => '20260526-guard-1',
'business_date' => '2026-05-26',
'sequence_no' => 1,
'status' => DrawStatus::Settling->value,
'start_time' => now()->subHour(),
'close_time' => now()->subMinutes(30),
'draw_time' => now()->subMinutes(20),
'cooling_end_time' => null,
'result_source' => 'manual',
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$published = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'manual',
'rng_seed_hash' => null,
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
$pending = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 2,
'source_type' => 'manual',
'rng_seed_hash' => null,
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::PendingReview->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => null,
]);
SettlementBatch::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $published->id,
'settle_version' => 1,
'status' => SettlementBatchStatus::PendingReview->value,
'review_status' => 'pending',
'started_at' => now(),
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$pending->id}/publish")
->assertStatus(409);
});
test('cannot create second pending result batch for same draw', function (): void {
$token = guardsAdminToken();
$draw = Draw::query()->create([
'draw_no' => '20260526-guard-2',
'business_date' => '2026-05-26',
'sequence_no' => 2,
'status' => DrawStatus::Review->value,
'start_time' => now()->subHour(),
'close_time' => now()->subMinutes(30),
'draw_time' => now()->subMinutes(20),
'cooling_end_time' => null,
'result_source' => 'manual',
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$items = [];
foreach (DrawPrizeLayout::slots() as $i => $slot) {
$items[] = [
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT),
];
}
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items])
->assertOk();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items])
->assertStatus(409);
});
test('admin cannot manually process transfer out pending reconcile order', function (): void {
$token = guardsAdminToken();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'out-manual-block',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
TransferOrder::query()->create([
'transfer_no' => 'TO_manual_block',
'player_id' => $player->id,
'direction' => 'out',
'currency_code' => 'NPR',
'amount' => 300,
'idempotent_key' => 'out-manual-block-key',
'status' => 'pending_reconcile',
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => 'main_site_timeout',
'finished_at' => null,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/wallet/transfer-orders/TO_manual_block/manually-process')
->assertStatus(422);
expect(TransferOrder::query()->where('transfer_no', 'TO_manual_block')->value('status'))
->toBe('pending_reconcile');
});
test('admin cannot complete credit for main site timeout transfer in order', function (): void {
$token = guardsAdminToken();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'in-complete-block',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 100,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
TransferOrder::query()->create([
'transfer_no' => 'TI_complete_block',
'player_id' => $player->id,
'direction' => 'in',
'currency_code' => 'NPR',
'amount' => 500,
'idempotent_key' => 'in-complete-block-key',
'status' => 'pending_reconcile',
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => 'main_site_timeout',
'finished_at' => null,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_block/complete-credit')
->assertStatus(422);
expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(100);
});
test('transfer order list hides manual process for out pending reconcile', function (): void {
$token = guardsAdminToken();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'list-flags',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
TransferOrder::query()->create([
'transfer_no' => 'TO_list_flags',
'player_id' => $player->id,
'direction' => 'out',
'currency_code' => 'NPR',
'amount' => 100,
'idempotent_key' => 'list-flags-key',
'status' => 'pending_reconcile',
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => 'main_site_timeout',
'finished_at' => null,
]);
$items = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/wallet/transfer-orders?player_id='.$player->id)
->assertOk()
->json('data.items');
$item = collect($items)->firstWhere('transfer_no', 'TO_list_flags');
expect($item)->not->toBeNull();
expect($item['can_reverse'])->toBeTrue()
->and($item['can_manually_process'])->toBeFalse()
->and($item['can_complete_credit'])->toBeFalse();
});

View File

@@ -0,0 +1,110 @@
<?php
use App\Models\AdminUser;
use App\Models\AuditLog;
use App\Models\JackpotPool;
use App\Models\JackpotPoolAdjustment;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
function jackpotAdjustAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'jackpot_adjust_admin',
'name' => 'Jackpot Adjust',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin cannot set current_amount via pool update', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$token = jackpotAdjustAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [
'current_amount' => 9_999_999,
])
->assertStatus(422);
});
test('admin can apply jackpot pool balance adjustment with ledger row', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill(['current_amount' => 1_000])->save();
$token = jackpotAdjustAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments', [
'amount_delta' => 500,
'reason' => 'manual top-up after reconciliation',
])
->assertOk()
->assertJsonPath('data.pool.current_amount', 1_500)
->assertJsonPath('data.adjustment.amount_delta', 500)
->assertJsonPath('data.adjustment.balance_before', 1_000)
->assertJsonPath('data.adjustment.balance_after', 1_500);
expect(JackpotPoolAdjustment::query()->where('jackpot_pool_id', $pool->id)->count())->toBe(1);
expect(
AuditLog::query()
->where('module_code', 'jackpot')
->where('action_code', 'adjust_balance')
->exists(),
)->toBeTrue();
});
test('admin jackpot adjustment rejects negative balance', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill(['current_amount' => 100])->save();
$token = jackpotAdjustAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments', [
'amount_delta' => -200,
'reason' => 'correction',
])
->assertStatus(422);
expect((int) $pool->fresh()->current_amount)->toBe(100);
});
test('admin can list jackpot pool adjustments', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$admin = AdminUser::query()->create([
'username' => 'adj_list_admin',
'name' => 'List',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
JackpotPoolAdjustment::query()->create([
'adjustment_no' => 'JA_TEST_1',
'jackpot_pool_id' => $pool->id,
'admin_user_id' => $admin->id,
'amount_delta' => 50,
'balance_before' => 0,
'balance_after' => 50,
'reason' => 'seed',
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments')
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.adjustment_no', 'JA_TEST_1');
});

View File

@@ -159,6 +159,7 @@ test('admin transfer order list exposes available reconcile actions by status',
->and($byNo['TI_failed']['can_manually_process'])->toBeTrue()
->and($byNo['TI_wait']['can_reverse'])->toBeTrue()
->and($byNo['TI_wait']['can_manually_process'])->toBeTrue()
->and($byNo['TI_wait']['can_complete_credit'])->toBeFalse()
->and($byNo['TI_done']['can_reverse'])->toBeFalse()
->and($byNo['TI_done']['can_manually_process'])->toBeFalse();
});
@@ -326,6 +327,56 @@ test('admin transfer reverse is idempotent under concurrent reconcile', function
->and(WalletTxn::query()->where('biz_type', 'reversal')->count())->toBe(1);
});
test('admin can complete stuck transfer in credit for pending reconcile order', function (): void {
$token = makeAdminToken();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'complete-credit-player',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$wallet = PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 500,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
TransferOrder::query()->create([
'transfer_no' => 'TI_complete_credit',
'player_id' => $player->id,
'direction' => 'in',
'currency_code' => 'NPR',
'amount' => 2_000,
'idempotent_key' => 'complete-credit-key',
'status' => 'pending_reconcile',
'external_request_payload' => ['ok' => true],
'external_response_payload' => ['ok' => true],
'external_ref_no' => 'main-ref-1',
'fail_reason' => 'lottery_credit_failed',
'finished_at' => null,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_credit/complete-credit', [
'remark' => 'manual complete',
])
->assertOk()
->assertJsonPath('data.status', 'success');
$wallet->refresh();
expect((int) $wallet->balance)->toBe(2_500)
->and(TransferOrder::query()->where('transfer_no', 'TI_complete_credit')->value('status'))->toBe('success')
->and(WalletTxn::query()->where('biz_type', 'transfer_in')->count())->toBe(1);
});
test('admin shows player wallets', function (): void {
$token = makeAdminToken();

View File

@@ -2,8 +2,14 @@
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\WalletTxn;
use App\Models\TicketCombination;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -286,6 +292,132 @@ test('admin can cancel draw before results exist', function (): void {
Carbon::setTestNow();
});
test('admin cancel draw refunds open bets and releases risk', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 12:16:00', 'UTC'));
$draw = Draw::query()->create([
'draw_no' => '20260509-121b',
'business_date' => '2026-05-09',
'sequence_no' => 121,
'status' => DrawStatus::Open->value,
'start_time' => now()->copy()->subMinute(),
'close_time' => now()->copy()->addMinutes(10),
'draw_time' => now()->copy()->addMinutes(15),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'cancel-refund-player',
'username' => 'cancel_refund',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$wallet = PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 49_900,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$order = TicketOrder::query()->create([
'order_no' => 'TO-CANCEL-REFUND',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 3000,
'status' => 'placed',
'submit_source' => 'h5',
'client_trace_id' => 'cancel-refund-trace',
]);
$item = TicketItem::query()->create([
'ticket_no' => 'TK-CANCEL-REFUND',
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => 'straight',
'unit_bet_amount' => 100,
'total_bet_amount' => 100,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 100,
'odds_snapshot_json' => [],
'rule_snapshot_json' => [],
'combination_count' => 1,
'estimated_max_payout' => 3000,
'risk_locked_amount' => 3000,
'status' => 'pending_draw',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 100,
'estimated_payout' => 3000,
]);
WalletTxn::query()->create([
'txn_no' => 'WT-CANCEL-REFUND',
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => 'bet_deduct',
'biz_no' => $order->order_no,
'direction' => 2,
'amount' => 100,
'balance_before' => 50_000,
'balance_after' => 49_900,
'status' => 'posted',
'external_ref_no' => null,
'idempotent_key' => 'bet:'.$order->order_no,
'remark' => null,
]);
$admin = AdminUser::query()->create([
'username' => 'draw_cancel_refund_admin',
'name' => 'Draw Cancel Refund Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/cancel")
->assertOk()
->assertJsonPath('data.status', DrawStatus::Cancelled->value);
expect($order->fresh()->status)->toBe('refunded')
->and($item->fresh()->status)->toBe('refunded')
->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(50_000);
Carbon::setTestNow();
});
test('admin can manually trigger rng for closed draw', function (): void {
config(['lottery.draw.require_manual_review' => true]);
Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC'));

View File

@@ -333,6 +333,93 @@ test('ticket place is idempotent by player draw and client trace id', function (
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});
test('ticket place idempotency is scoped per draw not global trace', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw('20260511-001');
Draw::query()->create([
'draw_no' => '20260511-002',
'business_date' => '2026-05-11',
'sequence_no' => 2,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(2),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$trace = 'shared-trace-two-draws';
$payloadA = array_merge(ticketPreviewPayload('20260511-001'), ['client_trace_id' => $trace]);
$payloadB = array_merge(ticketPreviewPayload('20260511-002'), ['client_trace_id' => $trace]);
$first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payloadA)
->assertOk()
->json('data.order_no');
$second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payloadB)
->assertOk()
->json('data.order_no');
expect($second)->not->toBe($first)
->and(TicketOrder::query()->count())->toBe(2);
});
test('ticket place accepts draw pending in db when hall rules show open', function (): void {
$player = ticketPlayerWithWallet();
Draw::query()->create([
'draw_no' => '20260511-pending-hall',
'business_date' => '2026-05-11',
'sequence_no' => 99,
'status' => DrawStatus::Pending->value,
'start_time' => now()->subMinute(),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-pending-hall',
'currency_code' => 'NPR',
'client_trace_id' => 'pending-hall-open-place',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
expect(TicketOrder::query()->where('client_trace_id', 'pending-hall-open-place')->exists())->toBeTrue();
});
test('ticket place rejects bet when only frozen balance would cover stake', function (): void {
$player = ticketPlayerWithWallet(20_000);
ticketOpenDraw();
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
$wallet->forceFill(['balance' => 20_000, 'frozen_balance' => 19_000])->save();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', array_merge(ticketPreviewPayload(), [
'client_trace_id' => 'frozen-balance-guard',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
]))
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);
});
test('box family estimated max payout is the sum of every expanded combination payout', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
@@ -1043,3 +1130,143 @@ test('ticket place reverses wallet and releases risk when post deduction confirm
->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1)
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
});
test('ticket place idempotency replays refunded order for same trace', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$trace = 'trace-refunded-replay';
$order = TicketOrder::query()->create([
'order_no' => 'TO-REFUNDED-IDEM',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 3000,
'status' => 'refunded',
'submit_source' => 'h5',
'client_trace_id' => $trace,
]);
$payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => $trace]);
$replay = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->json('data');
expect($replay['order_no'])->toBe($order->order_no)
->and(TicketOrder::query()->count())->toBe(1)
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket pending confirmation reconcile refunds when draw no longer accepts bets', function (): void {
$draw = ticketOpenDraw();
$draw->forceFill([
'status' => DrawStatus::Closed->value,
'close_time' => now()->subMinute(),
'draw_time' => now()->subMinute(),
])->save();
$player = ticketPlayerWithWallet(10_000);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
$order = TicketOrder::query()->create([
'order_no' => 'TO-PENDING-CLOSED',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 3000,
'status' => 'pending_confirm',
'submit_source' => 'h5',
'client_trace_id' => 'pending-on-closed-draw',
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);
$item = TicketItem::query()->create([
'ticket_no' => 'TK-PENDING-CLOSED',
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => 'straight',
'unit_bet_amount' => 100,
'total_bet_amount' => 100,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 100,
'odds_snapshot_json' => [],
'rule_snapshot_json' => [],
'combination_count' => 1,
'estimated_max_payout' => 3000,
'risk_locked_amount' => 3000,
'status' => 'pending_confirm',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 100,
'estimated_payout' => 3000,
'created_at' => now()->subMinutes(20),
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 3000,
'remaining_amount' => 2000,
'sold_out_status' => 0,
'version' => 1,
]);
$wallet->forceFill(['balance' => 9_900])->save();
WalletTxn::query()->create([
'txn_no' => 'WL-PENDING-CLOSED',
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => 'bet_deduct',
'biz_no' => 'TO-PENDING-CLOSED',
'direction' => 2,
'amount' => 100,
'balance_before' => 10_000,
'balance_after' => 9_900,
'status' => 'posted',
'external_ref_no' => null,
'idempotent_key' => 'bet_deduct:TO-PENDING-CLOSED',
'remark' => null,
]);
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
->expectsOutputToContain('refunded: 1')
->assertExitCode(0);
expect($order->fresh()->status)->toBe('refunded')
->and($item->fresh()->status)->toBe('refunded')
->and($item->fresh()->fail_reason_code)->toBe('draw_no_longer_open')
->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(10_000)
->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1)
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
});

View File

@@ -122,6 +122,48 @@ test('transfer out debits lottery and matches stub credit', function () {
expect((int) PlayerWallet::query()->where('player_id', $player->id)->first()?->balance)->toBe(600);
});
test('transfer out respects frozen balance when checking available funds', function () {
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'u-frozen',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 10_000,
'frozen_balance' => 9_500,
'status' => 0,
'version' => 0,
]);
$code = ErrorCode::WalletInsufficientBalance->value;
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/wallet/transfer-out', [
'amount' => 1_000,
'idempotent_key' => 'idem-out-frozen-too-much',
])
->assertStatus(400)
->assertJsonPath('code', $code);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/wallet/transfer-out', [
'amount' => 400,
'idempotent_key' => 'idem-out-frozen-ok',
])
->assertOk()
->assertJsonPath('data.lottery_balance_after', 9_600);
expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(9_600)
->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('frozen_balance'))->toBe(9_500);
});
test('transfer out insufficient balance fails with 1001', function () {
$player = Player::query()->create([
'site_code' => 'main',