feat: 增强奖池与钱包管理功能
更新 AdminJackpotPoolUpdateController 校验规则,禁止传入 current_amount。 优化 AdminRiskPoolManualStatusController:更新奖池状态后同步 Redis 状态。 在 TransferOrderReconcileController 中新增 completeCredit 方法,用于处理卡住的转账订单对账。 调整 TransferOrderListController:优化转账订单处理条件。 在 TicketItemsIndexController 中实现支持时区的日期筛选,提升日期处理准确性。 扩展 JackpotPool 模型,新增 adjustments 关联关系。 改进票据与钱包相关服务中的错误处理和事务管理。
This commit is contained in:
@@ -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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ final class AdminJackpotPoolUpdateController extends Controller
|
|||||||
public function __invoke(Request $request, JackpotPool $pool): JsonResponse
|
public function __invoke(Request $request, JackpotPool $pool): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'current_amount' => 'sometimes|integer|min:0',
|
'current_amount' => 'prohibited',
|
||||||
'contribution_rate' => 'sometimes|numeric|min:0|max:1',
|
'contribution_rate' => 'sometimes|numeric|min:0|max:1',
|
||||||
'trigger_threshold' => 'sometimes|integer|min:0',
|
'trigger_threshold' => 'sometimes|integer|min:0',
|
||||||
'payout_rate' => 'sometimes|numeric|min:0|max:1',
|
'payout_rate' => 'sometimes|numeric|min:0|max:1',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,13 @@ use App\Models\RiskPoolLockLog;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Ticket\RiskPoolService;
|
||||||
|
|
||||||
final class AdminRiskPoolManualStatusController extends Controller
|
final class AdminRiskPoolManualStatusController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RiskPoolService $riskPoolService,
|
||||||
|
) {}
|
||||||
public function close(Draw $draw, string $number_4d): JsonResponse
|
public function close(Draw $draw, string $number_4d): JsonResponse
|
||||||
{
|
{
|
||||||
$pool = $this->updateStatus($draw, $number_4d, true, 'close', 'admin_manual_close');
|
$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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,14 @@ final class TransferOrderListController extends Controller
|
|||||||
'idempotent_key' => $o->idempotent_key,
|
'idempotent_key' => $o->idempotent_key,
|
||||||
'status' => $o->status,
|
'status' => $o->status,
|
||||||
'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile',
|
'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_ref_no' => $o->external_ref_no,
|
||||||
'external_request_payload' => $o->external_request_payload,
|
'external_request_payload' => $o->external_request_payload,
|
||||||
'external_response_payload' => $o->external_response_payload,
|
'external_response_payload' => $o->external_response_payload,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Services\Wallet\LotteryTransferService;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Wallet\TransferOrderReverseRequest;
|
use App\Http\Requests\Admin\Wallet\TransferOrderReverseRequest;
|
||||||
use App\Http\Requests\Admin\Wallet\TransferOrderManuallyProcessRequest;
|
use App\Http\Requests\Admin\Wallet\TransferOrderManuallyProcessRequest;
|
||||||
|
use App\Http\Requests\Admin\Wallet\TransferOrderCompleteCreditRequest;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,4 +72,29 @@ final class TransferOrderReconcileController extends Controller
|
|||||||
|
|
||||||
return ApiResponse::success(['transfer_no' => $transferNo, 'status' => 'manually_processed']);
|
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ final class TicketDrawMyMatchController extends Controller
|
|||||||
$itemIds = TicketItem::query()
|
$itemIds = TicketItem::query()
|
||||||
->where('draw_id', $draw->id)
|
->where('draw_id', $draw->id)
|
||||||
->where('player_id', $player->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');
|
->pluck('id');
|
||||||
|
|
||||||
$hasBets = $itemIds->isNotEmpty();
|
$hasBets = $itemIds->isNotEmpty();
|
||||||
$winningItemIds = TicketItem::query()
|
$winningItemIds = TicketItem::query()
|
||||||
->where('draw_id', $draw->id)
|
->where('draw_id', $draw->id)
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
->where('status', 'settled_win')
|
->whereIn('status', ['settled_win', 'pending_payout'])
|
||||||
->where(function ($q): void {
|
->where(function ($q): void {
|
||||||
$q->where('win_amount', '>', 0)
|
$q->where('win_amount', '>', 0)
|
||||||
->orWhere('jackpot_win_amount', '>', 0);
|
->orWhere('jackpot_win_amount', '>', 0);
|
||||||
@@ -90,7 +90,7 @@ final class TicketDrawMyMatchController extends Controller
|
|||||||
$sums = TicketItem::query()
|
$sums = TicketItem::query()
|
||||||
->where('draw_id', $draw->id)
|
->where('draw_id', $draw->id)
|
||||||
->where('player_id', $player->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')
|
->selectRaw('coalesce(sum(win_amount),0) as sum_win, coalesce(sum(jackpot_win_amount),0) as sum_jackpot')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ final class TicketItemShowController extends Controller
|
|||||||
->where('biz_no', $order?->order_no)
|
->where('biz_no', $order?->order_no)
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->first();
|
->first();
|
||||||
$payoutTxn = WalletTxn::query()
|
$payoutTxn = $settlementBatch !== null
|
||||||
->where('player_id', $player->id)
|
? WalletTxn::query()
|
||||||
->where('biz_type', 'settle_payout')
|
->where('player_id', $player->id)
|
||||||
->where('biz_no', 'like', 'SB%')
|
->where('biz_type', 'settle_payout')
|
||||||
->orderByDesc('id')
|
->where('biz_no', 'SB'.$settlementBatch->id)
|
||||||
->first();
|
->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
$timeline = [];
|
$timeline = [];
|
||||||
if ($order?->created_at !== null) {
|
if ($order?->created_at !== null) {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\Ticket;
|
namespace App\Http\Controllers\Api\V1\Ticket;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\WalletTxn;
|
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Support\PaginationTrait;
|
use App\Support\PaginationTrait;
|
||||||
@@ -65,11 +65,13 @@ final class TicketItemsIndexController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($startDate !== null) {
|
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) {
|
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);
|
$paginator = $query->paginate(perPage: $perPage, page: $page);
|
||||||
@@ -119,4 +121,23 @@ final class TicketItemsIndexController extends Controller
|
|||||||
|
|
||||||
return $value;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ final class WalletLogsController extends Controller
|
|||||||
'transfer_out' => ['transfer_out'],
|
'transfer_out' => ['transfer_out'],
|
||||||
'refund' => ['transfer_out_refund'],
|
'refund' => ['transfer_out_refund'],
|
||||||
'reversal' => ['reversal'],
|
'reversal' => ['reversal'],
|
||||||
'bet' => ['bet_deduct', 'bet'],
|
'bet' => ['bet_deduct', 'bet', 'bet_reverse'],
|
||||||
'prize' => ['settle_payout', 'prize'],
|
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __invoke(Request $request): JsonResponse
|
public function __invoke(Request $request): JsonResponse
|
||||||
@@ -153,8 +153,9 @@ final class WalletLogsController extends Controller
|
|||||||
{
|
{
|
||||||
return match ($biz) {
|
return match ($biz) {
|
||||||
'transfer_out_refund' => 'refund',
|
'transfer_out_refund' => 'refund',
|
||||||
'bet_deduct' => 'bet',
|
'bet_deduct', 'bet' => 'bet',
|
||||||
'settle_payout' => 'prize',
|
'bet_reverse' => 'reversal',
|
||||||
|
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
|
||||||
'reversal' => 'reversal',
|
'reversal' => 'reversal',
|
||||||
default => $biz,
|
default => $biz,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,4 +46,9 @@ final class JackpotPool extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(JackpotContribution::class, 'jackpot_pool_id');
|
return $this->hasMany(JackpotContribution::class, 'jackpot_pool_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function adjustments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(JackpotPoolAdjustment::class, 'jackpot_pool_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Models/JackpotPoolAdjustment.php
Normal file
41
app/Models/JackpotPoolAdjustment.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
final class DrawAdminActionService
|
final class DrawAdminActionService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DrawCancelBetRefundService $cancelBetRefund,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function manualClose(Draw $draw): Draw
|
public function manualClose(Draw $draw): Draw
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($draw): Draw {
|
return DB::transaction(function () use ($draw): Draw {
|
||||||
@@ -43,6 +47,8 @@ final class DrawAdminActionService
|
|||||||
throw new \RuntimeException('draw_result_exists');
|
throw new \RuntimeException('draw_result_exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->cancelBetRefund->refundOpenBetsForDraw($locked);
|
||||||
|
|
||||||
$locked->forceFill(['status' => DrawStatus::Cancelled->value])->save();
|
$locked->forceFill(['status' => DrawStatus::Cancelled->value])->save();
|
||||||
|
|
||||||
return $locked->refresh();
|
return $locked->refresh();
|
||||||
|
|||||||
91
app/Services/Draw/DrawCancelBetRefundService.php
Normal file
91
app/Services/Draw/DrawCancelBetRefundService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,14 @@ final class DrawHallSnapshotBuilder
|
|||||||
return DrawStatus::Closing->value;
|
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
|
private function showsPublishedResults(string $drawStatus): bool
|
||||||
{
|
{
|
||||||
return in_array($drawStatus, [
|
return in_array($drawStatus, [
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ final class DrawManualResultService
|
|||||||
throw new \RuntimeException('draw_already_settled');
|
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);
|
$nextVersion = max(1, (int) $locked->current_result_version + 1);
|
||||||
$batch = DrawResultBatch::query()->create([
|
$batch = DrawResultBatch::query()->create([
|
||||||
'draw_id' => $locked->id,
|
'draw_id' => $locked->id,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ use App\Models\Draw;
|
|||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Services\LotterySettings;
|
use App\Services\LotterySettings;
|
||||||
use App\Lottery\DrawResultBatchStatus;
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
|
use App\Lottery\SettlementBatchStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 人工审核通过后发布结果;或 RNG 自动生成路径内联调用同一事务字段更新。
|
* 人工审核通过后发布结果;或 RNG 自动生成路径内联调用同一事务字段更新。
|
||||||
@@ -114,10 +116,28 @@ final class DrawPublishService
|
|||||||
DrawStatus::Closed->value,
|
DrawStatus::Closed->value,
|
||||||
DrawStatus::Review->value,
|
DrawStatus::Review->value,
|
||||||
DrawStatus::Cooldown->value,
|
DrawStatus::Cooldown->value,
|
||||||
DrawStatus::Settling->value,
|
|
||||||
];
|
];
|
||||||
if (! in_array($draw->status, $allowed, true)) {
|
if (! in_array($draw->status, $allowed, true)) {
|
||||||
throw new \RuntimeException('draw_not_ready_to_publish');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ final class JackpotContributionService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$existing = JackpotContribution::query()
|
||||||
|
->where('ticket_item_id', $item->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
JackpotContribution::query()->create([
|
JackpotContribution::query()->create([
|
||||||
'jackpot_pool_id' => $pool->id,
|
'jackpot_pool_id' => $pool->id,
|
||||||
'draw_id' => $draw->id,
|
'draw_id' => $draw->id,
|
||||||
|
|||||||
95
app/Services/Jackpot/JackpotPoolAdjustmentService.php
Normal file
95
app/Services/Jackpot/JackpotPoolAdjustmentService.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,11 +97,15 @@ final class SettlementBatchWorkflowService
|
|||||||
|
|
||||||
$batchItemIds = $locked->details()->pluck('ticket_item_id')->map(fn ($id) => (int) $id)->all();
|
$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('draw_id', $locked->draw_id)
|
||||||
->where('status', 'pending_draw')
|
->whereIn('status', [
|
||||||
|
'pending_confirm',
|
||||||
|
'partial_pending_confirm',
|
||||||
|
'pending_draw',
|
||||||
|
])
|
||||||
->exists();
|
->exists();
|
||||||
if ($hasPendingDraw) {
|
if ($hasUnsettled) {
|
||||||
throw new \RuntimeException('draw_has_unsettled_tickets');
|
throw new \RuntimeException('draw_has_unsettled_tickets');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ final class SettlementOrchestrator
|
|||||||
->where('draw_id', $locked->id)
|
->where('draw_id', $locked->id)
|
||||||
->where('result_batch_id', $publishedBatch->id)
|
->where('result_batch_id', $publishedBatch->id)
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
|
SettlementBatchStatus::Running->value,
|
||||||
SettlementBatchStatus::PendingReview->value,
|
SettlementBatchStatus::PendingReview->value,
|
||||||
SettlementBatchStatus::Approved->value,
|
SettlementBatchStatus::Approved->value,
|
||||||
SettlementBatchStatus::Paid->value,
|
SettlementBatchStatus::Paid->value,
|
||||||
|
|||||||
@@ -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
|
private function shouldUseRedisAtomicLocks(): bool
|
||||||
{
|
{
|
||||||
if (App::environment('testing')) {
|
if (App::environment('testing')) {
|
||||||
@@ -196,6 +218,14 @@ return 1
|
|||||||
LUA;
|
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
|
private function acquireLua(): string
|
||||||
{
|
{
|
||||||
return <<<'LUA'
|
return <<<'LUA'
|
||||||
|
|||||||
@@ -2,15 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Services\Ticket;
|
namespace App\Services\Ticket;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
use App\Models\WalletTxn;
|
use App\Models\WalletTxn;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\TicketOrder;
|
use App\Models\TicketOrder;
|
||||||
|
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||||
|
use App\Services\Jackpot\JackpotContributionService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class TicketPendingConfirmReconcileService
|
final class TicketPendingConfirmReconcileService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RiskPoolService $riskPool,
|
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);
|
$cutoff = now()->subMinutes($staleMinutes);
|
||||||
$orders = TicketOrder::query()
|
$orders = TicketOrder::query()
|
||||||
->where('status', 'pending_confirm')
|
->whereIn('status', ['pending_confirm', 'partial_pending_confirm'])
|
||||||
->where('updated_at', '<=', $cutoff)
|
->where('updated_at', '<=', $cutoff)
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
@@ -35,7 +41,7 @@ final class TicketPendingConfirmReconcileService
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($lockedOrder === null || $lockedOrder->status !== 'pending_confirm') {
|
if ($lockedOrder === null || ! in_array($lockedOrder->status, ['pending_confirm', 'partial_pending_confirm'], true)) {
|
||||||
return 'skipped';
|
return 'skipped';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,52 +52,10 @@ final class TicketPendingConfirmReconcileService
|
|||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($hasPostedDeduct) {
|
if ($hasPostedDeduct) {
|
||||||
TicketItem::query()
|
return $this->confirmOrder($lockedOrder);
|
||||||
->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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = TicketItem::query()
|
return $this->refundOrderWithoutDeduct($lockedOrder);
|
||||||
->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';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($result === 'skipped') {
|
if ($result === 'skipped') {
|
||||||
@@ -109,4 +73,104 @@ final class TicketPendingConfirmReconcileService
|
|||||||
|
|
||||||
return $summary;
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ use App\Models\Player;
|
|||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\WalletTxn;
|
use App\Models\WalletTxn;
|
||||||
use App\Lottery\DrawStatus;
|
|
||||||
use App\Models\TicketOrder;
|
use App\Models\TicketOrder;
|
||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\TicketCombination;
|
use App\Models\TicketCombination;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Exceptions\TicketOperationException;
|
use App\Exceptions\TicketOperationException;
|
||||||
use App\Services\Jackpot\JackpotContributionService;
|
use App\Services\Jackpot\JackpotContributionService;
|
||||||
|
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||||
|
|
||||||
final class TicketPlacementService
|
final class TicketPlacementService
|
||||||
{
|
{
|
||||||
@@ -23,6 +23,7 @@ final class TicketPlacementService
|
|||||||
private readonly RiskPoolService $riskPoolService,
|
private readonly RiskPoolService $riskPoolService,
|
||||||
private readonly TicketWalletService $ticketWalletService,
|
private readonly TicketWalletService $ticketWalletService,
|
||||||
private readonly JackpotContributionService $jackpotContribution,
|
private readonly JackpotContributionService $jackpotContribution,
|
||||||
|
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,11 +37,14 @@ final class TicketPlacementService
|
|||||||
? (string) $payload['client_trace_id']
|
? (string) $payload['client_trace_id']
|
||||||
: null;
|
: 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()
|
$existing = TicketOrder::query()
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
|
->where('draw_id', $drawIdForIdempotency)
|
||||||
->where('client_trace_id', $clientTraceId)
|
->where('client_trace_id', $clientTraceId)
|
||||||
->whereIn('status', ['placed', 'partial_failed'])
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing !== null) {
|
if ($existing !== null) {
|
||||||
@@ -72,7 +76,7 @@ final class TicketPlacementService
|
|||||||
if ($draw === null) {
|
if ($draw === null) {
|
||||||
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
|
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);
|
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('player_id', $player->id)
|
||||||
->where('wallet_type', 'lottery')
|
->where('wallet_type', 'lottery')
|
||||||
->where('currency_code', $currencyCode)
|
->where('currency_code', $currencyCode)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->value('balance') ?? 0);
|
->first();
|
||||||
if ($walletBalance < $totalActualDeduct) {
|
$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);
|
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +339,10 @@ final class TicketPlacementService
|
|||||||
$order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
|
$order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
|
||||||
$draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail();
|
$draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail();
|
||||||
$successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'pending_draw')->count();
|
$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();
|
$failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count();
|
||||||
if ($balanceAfter === null) {
|
if ($balanceAfter === null) {
|
||||||
$walletTxn = WalletTxn::query()
|
$walletTxn = WalletTxn::query()
|
||||||
@@ -350,11 +360,13 @@ final class TicketPlacementService
|
|||||||
'status' => $draw->status,
|
'status' => $draw->status,
|
||||||
],
|
],
|
||||||
'summary' => [
|
'summary' => [
|
||||||
|
'order_status' => $order->status,
|
||||||
'total_bet_amount' => (int) $order->total_bet_amount,
|
'total_bet_amount' => (int) $order->total_bet_amount,
|
||||||
'total_rebate_amount' => (int) $order->total_rebate_amount,
|
'total_rebate_amount' => (int) $order->total_rebate_amount,
|
||||||
'total_actual_deduct' => (int) $order->total_actual_deduct,
|
'total_actual_deduct' => (int) $order->total_actual_deduct,
|
||||||
'total_estimated_payout' => (int) $order->total_estimated_payout,
|
'total_estimated_payout' => (int) $order->total_estimated_payout,
|
||||||
'success_count' => $successCount,
|
'success_count' => $successCount,
|
||||||
|
'pending_confirm_count' => $pendingConfirmCount,
|
||||||
'failure_count' => $failureCount,
|
'failure_count' => $failureCount,
|
||||||
],
|
],
|
||||||
'balance_after' => $balanceAfter,
|
'balance_after' => $balanceAfter,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace App\Services\Ticket;
|
|||||||
|
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
use App\Lottery\DrawStatus;
|
|
||||||
use App\Exceptions\TicketOperationException;
|
use App\Exceptions\TicketOperationException;
|
||||||
|
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||||
|
|
||||||
final class TicketPreviewService
|
final class TicketPreviewService
|
||||||
{
|
{
|
||||||
@@ -13,6 +13,7 @@ final class TicketPreviewService
|
|||||||
private readonly PlayCatalogResolver $catalogResolver,
|
private readonly PlayCatalogResolver $catalogResolver,
|
||||||
private readonly PlayRuleEngine $ruleEngine,
|
private readonly PlayRuleEngine $ruleEngine,
|
||||||
private readonly RiskPoolService $riskPoolService,
|
private readonly RiskPoolService $riskPoolService,
|
||||||
|
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +26,7 @@ final class TicketPreviewService
|
|||||||
if ($draw === null) {
|
if ($draw === null) {
|
||||||
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
|
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);
|
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +109,13 @@ final class TicketPreviewService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$nowUtc = now()->utc();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'draw' => [
|
'draw' => [
|
||||||
'draw_id' => $draw->draw_no,
|
'draw_id' => $draw->draw_no,
|
||||||
'status' => $draw->status,
|
'status' => $this->drawHallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc),
|
||||||
|
'db_status' => $draw->status,
|
||||||
],
|
],
|
||||||
'config_versions' => $this->catalogResolver->currentActiveVersionStamp(),
|
'config_versions' => $this->catalogResolver->currentActiveVersionStamp(),
|
||||||
'summary' => [
|
'summary' => [
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ final class TicketWalletService
|
|||||||
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
|
$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;
|
$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);
|
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +67,7 @@ final class TicketWalletService
|
|||||||
'balance_after' => $after,
|
'balance_after' => $after,
|
||||||
'status' => self::TXN_POSTED,
|
'status' => self::TXN_POSTED,
|
||||||
'external_ref_no' => null,
|
'external_ref_no' => null,
|
||||||
'idempotent_key' => $order->client_trace_id,
|
'idempotent_key' => 'bet_deduct:'.$order->order_no,
|
||||||
'remark' => null,
|
'remark' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -191,6 +196,10 @@ final class TicketWalletService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$currency = strtoupper($currencyCode);
|
$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()
|
$wallet = PlayerWallet::query()
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
@@ -231,7 +240,7 @@ final class TicketWalletService
|
|||||||
'balance_after' => $after,
|
'balance_after' => $after,
|
||||||
'status' => self::TXN_POSTED,
|
'status' => self::TXN_POSTED,
|
||||||
'external_ref_no' => null,
|
'external_ref_no' => null,
|
||||||
'idempotent_key' => 'settle-payout:'.$settlementBatchId.':'.$player->id,
|
'idempotent_key' => $idempotentKey,
|
||||||
'remark' => null,
|
'remark' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,28 +135,44 @@ final class LotteryTransferService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void {
|
try {
|
||||||
$wallet = $this->lockLotteryWallet($player, $currencyCode);
|
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void {
|
||||||
$this->postLotteryWalletMovement(
|
$wallet = $this->lockLotteryWallet($player, $currencyCode);
|
||||||
wallet: $wallet,
|
$this->postLotteryWalletMovement(
|
||||||
bizType: self::BIZ_TRANSFER_IN,
|
wallet: $wallet,
|
||||||
direction: self::TXN_DIR_IN,
|
bizType: self::BIZ_TRANSFER_IN,
|
||||||
amountMinor: $amountMinor,
|
direction: self::TXN_DIR_IN,
|
||||||
bizNo: $transferNo,
|
amountMinor: $amountMinor,
|
||||||
externalRefNo: $main->externalRefNo,
|
bizNo: $transferNo,
|
||||||
idempotentKey: $idempotentKey,
|
externalRefNo: $main->externalRefNo,
|
||||||
remark: null,
|
idempotentKey: $idempotentKey,
|
||||||
deltaSign: 1,
|
remark: null,
|
||||||
);
|
deltaSign: 1,
|
||||||
|
);
|
||||||
|
|
||||||
$order->forceFill([
|
$order->forceFill([
|
||||||
'status' => self::ST_SUCCESS,
|
'status' => self::ST_SUCCESS,
|
||||||
'external_ref_no' => $main->externalRefNo,
|
'external_ref_no' => $main->externalRefNo,
|
||||||
'external_request_payload' => $main->requestPayload,
|
'external_request_payload' => $main->requestPayload,
|
||||||
'external_response_payload' => $main->responsePayload,
|
'external_response_payload' => $main->responsePayload,
|
||||||
'finished_at' => now(),
|
'finished_at' => now(),
|
||||||
])->save();
|
])->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(
|
return $this->successPayload(
|
||||||
TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(),
|
TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(),
|
||||||
@@ -328,6 +344,12 @@ final class LotteryTransferService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'complete_credit') {
|
||||||
|
DB::transaction(fn (): mixed => $this->completeStuckTransferInCredit($order, $remark));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'manually_process') {
|
if ($action === 'manually_process') {
|
||||||
DB::transaction(fn (): mixed => $this->doManuallyProcess($order, $remark));
|
DB::transaction(fn (): mixed => $this->doManuallyProcess($order, $remark));
|
||||||
|
|
||||||
@@ -388,6 +410,79 @@ final class LotteryTransferService
|
|||||||
])->save();
|
])->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
|
private function doManuallyProcess(TransferOrder $order, string $remark): void
|
||||||
{
|
{
|
||||||
/** @var TransferOrder $locked */
|
/** @var TransferOrder $locked */
|
||||||
@@ -406,6 +501,14 @@ final class LotteryTransferService
|
|||||||
return;
|
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([
|
$locked->forceFill([
|
||||||
'status' => self::ST_MANUALLY_PROCESSED,
|
'status' => self::ST_MANUALLY_PROCESSED,
|
||||||
'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'),
|
'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'),
|
||||||
@@ -413,6 +516,13 @@ final class LotteryTransferService
|
|||||||
])->save();
|
])->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
|
private function lockLotteryWalletById(int $playerId, string $currencyCode): PlayerWallet
|
||||||
{
|
{
|
||||||
$wallet = PlayerWallet::query()
|
$wallet = PlayerWallet::query()
|
||||||
@@ -699,7 +809,8 @@ final class LotteryTransferService
|
|||||||
bool $requireBalance = false,
|
bool $requireBalance = false,
|
||||||
): array {
|
): array {
|
||||||
$before = (int) $wallet->balance;
|
$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(
|
throw new WalletOperationException(
|
||||||
'insufficient_balance',
|
'insufficient_balance',
|
||||||
ErrorCode::WalletInsufficientBalance->value,
|
ErrorCode::WalletInsufficientBalance->value,
|
||||||
|
|||||||
@@ -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.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.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.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.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']],
|
['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.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.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.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.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.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']],
|
['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']],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
7
lang/en/jackpot.php
Normal 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
7
lang/ne/jackpot.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'adjustment_delta_zero' => 'समायोजन रकम शून्य हुन सक्दैन',
|
||||||
|
'adjustment_reason_required' => 'समायोजन कारण अनिवार्य छ',
|
||||||
|
'adjustment_negative_balance' => 'समायोजन पछि पूल ब्यालेन्स ऋणात्मक हुन सक्दैन',
|
||||||
|
];
|
||||||
7
lang/zh/jackpot.php
Normal file
7
lang/zh/jackpot.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'adjustment_delta_zero' => '调整金额不能为 0',
|
||||||
|
'adjustment_reason_required' => '请填写调整原因',
|
||||||
|
'adjustment_negative_balance' => '调整后奖池余额不能为负数',
|
||||||
|
];
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolIndexController;
|
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\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\AdminJackpotPoolManualBurstController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPayoutLogIndexController;
|
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\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');
|
->name('api.v1.admin.jackpot.payout-logs.index');
|
||||||
Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class)
|
Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class)
|
||||||
->name('api.v1.admin.jackpot.contributions.index');
|
->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 {
|
->group(function (): void {
|
||||||
Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)
|
Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)
|
||||||
->name('api.v1.admin.jackpot.pools.update');
|
->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)
|
Route::post('jackpot/pools/{pool}/manual-burst', AdminJackpotPoolManualBurstController::class)
|
||||||
->name('api.v1.admin.jackpot.pools.manual-burst');
|
->name('api.v1.admin.jackpot.pools.manual-burst');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Route::middleware('admin.api-resource')
|
|||||||
->name('api.v1.admin.wallet.transfer-orders.reverse');
|
->name('api.v1.admin.wallet.transfer-orders.reverse');
|
||||||
Route::post('wallet/transfer-orders/{transfer_no}/manually-process', [TransferOrderReconcileController::class, 'manuallyProcess'])
|
Route::post('wallet/transfer-orders/{transfer_no}/manually-process', [TransferOrderReconcileController::class, 'manuallyProcess'])
|
||||||
->name('api.v1.admin.wallet.transfer-orders.manually-process');
|
->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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 对账任务创建(仅管理权限)
|
// 对账任务创建(仅管理权限)
|
||||||
|
|||||||
243
tests/Feature/AdminBusinessLogicGuardsTest.php
Normal file
243
tests/Feature/AdminBusinessLogicGuardsTest.php
Normal 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();
|
||||||
|
});
|
||||||
110
tests/Feature/AdminJackpotPoolAdjustmentTest.php
Normal file
110
tests/Feature/AdminJackpotPoolAdjustmentTest.php
Normal 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');
|
||||||
|
});
|
||||||
@@ -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_failed']['can_manually_process'])->toBeTrue()
|
||||||
->and($byNo['TI_wait']['can_reverse'])->toBeTrue()
|
->and($byNo['TI_wait']['can_reverse'])->toBeTrue()
|
||||||
->and($byNo['TI_wait']['can_manually_process'])->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_reverse'])->toBeFalse()
|
||||||
->and($byNo['TI_done']['can_manually_process'])->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);
|
->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 {
|
test('admin shows player wallets', function (): void {
|
||||||
$token = makeAdminToken();
|
$token = makeAdminToken();
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
|
use App\Models\Player;
|
||||||
use App\Models\AdminRole;
|
use App\Models\AdminRole;
|
||||||
use App\Models\AdminUser;
|
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\Lottery\DrawStatus;
|
||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
@@ -286,6 +292,132 @@ test('admin can cancel draw before results exist', function (): void {
|
|||||||
Carbon::setTestNow();
|
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 {
|
test('admin can manually trigger rng for closed draw', function (): void {
|
||||||
config(['lottery.draw.require_manual_review' => true]);
|
config(['lottery.draw.require_manual_review' => true]);
|
||||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC'));
|
Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC'));
|
||||||
|
|||||||
@@ -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);
|
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 {
|
test('box family estimated max payout is the sum of every expanded combination payout', function (): void {
|
||||||
$player = ticketPlayerWithWallet(500_000);
|
$player = ticketPlayerWithWallet(500_000);
|
||||||
ticketOpenDraw();
|
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(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);
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
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 () {
|
test('transfer out insufficient balance fails with 1001', function () {
|
||||||
$player = Player::query()->create([
|
$player = Player::query()->create([
|
||||||
'site_code' => 'main',
|
'site_code' => 'main',
|
||||||
|
|||||||
Reference in New Issue
Block a user