Compare commits

...

10 Commits

Author SHA1 Message Date
xboard
4f48e61a2c fix(order): prevent duplicate refunds and redemptions
Refs #1021
2026-08-30 04:50:34 +08:00
asukassa
9d125527cb
fix: reindex device IP array before JSON encoding (#1045) 2026-08-30 01:07:37 +08:00
JW-3154
e074bef702
fix(Shadowrocket.php): add missing bandwidth params for Hysteria2 (#1031)
* fix(Shadowrocket): add missing bandwidth params for Hysteria2

Fixes a critical issue where Hysteria 2 silently downgrades to non-Brutal congestion control (BBR/Reno) on Shadowrocket clients.

* Refactor bandwidth parameters assignment

Update bandwidth parameters to only include positive values.

---------

Co-authored-by: Xboard <147830212+cedar2025@users.noreply.github.com>
2026-08-30 00:55:14 +08:00
muyichun
7cfe061469
fix: correct Alipay F2F request timestamp (#1024)
Co-authored-by: muyichun <25608102+muyichun@users.noreply.github.com>
2026-08-30 00:42:46 +08:00
yootus
c9ef5768e0 fix(order): calculate invitation commission after balance deduction (#1020)
Calculate invitation commission from the amount remaining after balance deductions, preventing duplicate commission on internally credited payments.
2026-08-30 00:39:20 +08:00
JW-3154
60ba57142b
fix (General.php): VLESS parsing issue (#1017)
Replace urlencode with rawurlencode to prevent spaces from being improperly converted to '+' in node names (e.g., 'HKG 01' becoming 'HKG+01'). 

A tiny one-line change that significantly improves the UX.
2026-08-07 21:44:44 +08:00
xboard
8ecb762d77 fix(admin): keep ticket detail open after reply, etc. 2026-06-18 00:56:23 +08:00
JW-3154
6141430a88 Fix TUIC congestion control at Shadowrocket.php (#968)
Fixes an issue where the TUIC congestion_control parameter was not being passed to clients on Shadowrocket. This PR adds the missing parameter to the URL query string, fetching it from $protocol_settings with a default and safe fallback to 'cubic', ensuring panel configurations are properly respected by the client.
2026-06-06 11:11:01 +08:00
socksprox
34bb1fdbcf Rename v2_order.refund_amount to surplus_credit (#967)
The old name was misleading — this column tracks surplus credit returned
to the user's internal balance during plan upgrades, not a payment
gateway refund. The new name aligns with the existing surplus_amount
and surplus_order_ids naming convention.

Co-authored-by: socksprox <info@shadowfly.net>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:49:58 +08:00
xboard
8f66a9af94 feat: add admin user plugin hooks
Added hooks:

- admin.user.fetch.query

- admin.user.transform

- admin.user.detail

- admin.user.update.rules

- admin.user.update.messages

- admin.user.update.params

- admin.user.update.before

- admin.user.update.after

- admin.user.secret.reset

- admin.user.destroy.before

- admin.user.destroy.after
2026-05-26 02:23:23 +08:00
20 changed files with 418 additions and 52 deletions

View File

@ -126,6 +126,9 @@ class OrderController extends Controller
if (!$order) {
return $this->fail([400, __('Order does not exist or has been paid')]);
}
if ($order->total_amount < 0) {
return $this->fail([400, __('Order amount is invalid')]);
}
// free process
if ($order->total_amount <= 0) {
$orderService = new OrderService($order);

View File

@ -11,6 +11,7 @@ use App\Models\Plan;
use App\Models\User;
use App\Services\AuthService;
use App\Services\NodeSyncService;
use App\Services\Plugin\HookManager;
use App\Services\UserService;
use App\Traits\QueryOperators;
use App\Utils\Helper;
@ -33,7 +34,16 @@ class UserController extends Controller
return $this->fail([400202, '用户不存在']);
$user->token = Helper::guid();
$user->uuid = Helper::guid(true);
return $this->success($user->save());
$result = $user->save();
if ($result) {
HookManager::call('admin.user.secret.reset', [
'user' => $user,
'request' => $request,
]);
}
return $this->success($result);
}
// Apply filters and sorts to the query builder.
@ -178,6 +188,8 @@ class UserController extends Controller
->select((new User())->getTable() . '.*')
->selectRaw('(u + d) as total_used');
$userModel = HookManager::filter('admin.user.fetch.query', $userModel, $request);
$this->applyFiltersAndSorts($request, $userModel);
$users = $userModel->orderBy('id', 'desc')
@ -193,11 +205,12 @@ class UserController extends Controller
// Transform user fields for API response.
public static function transformUserData(User $user): array
{
$model = $user;
$user = $user->toArray();
$user['balance'] = $user['balance'] / 100;
$user['commission_balance'] = $user['commission_balance'] / 100;
$user['subscribe_url'] = Helper::getSubscribeUrl($user['token']);
return $user;
return HookManager::filter('admin.user.transform', $user, $model);
}
public function getUserInfoById(Request $request)
@ -208,6 +221,7 @@ class UserController extends Controller
'id.required' => '用户ID不能为空'
]);
$user = User::find($request->input('id'))->load('invite_user');
$user = HookManager::filter('admin.user.detail', $user, $request);
return $this->success($user);
}
@ -257,12 +271,27 @@ class UserController extends Controller
$params['commission_balance'] = $params['commission_balance'] * 100;
}
$params = HookManager::filter('admin.user.update.params', $params, $request, $user);
HookManager::call('admin.user.update.before', [
'user' => $user,
'params' => $params,
'request' => $request,
]);
try {
$user->update($params);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([500, '保存失败']);
}
HookManager::call('admin.user.update.after', [
'user' => $user->refresh(),
'params' => $params,
'request' => $request,
]);
return $this->success(true);
}
@ -664,6 +693,11 @@ class UserController extends Controller
'id.exists' => '用户不存在'
]);
$user = User::find($request->input('id'));
HookManager::call('admin.user.destroy.before', [
'user' => $user,
'request' => $request,
]);
try {
DB::beginTransaction();
$user->orders()->delete();
@ -672,6 +706,12 @@ class UserController extends Controller
$user->tickets()->delete();
$user->delete();
DB::commit();
HookManager::call('admin.user.destroy.after', [
'user' => $user,
'request' => $request,
]);
return $this->success(true);
} catch (\Exception $e) {
DB::rollBack();

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin;
use App\Services\Plugin\HookManager;
use Illuminate\Foundation\Http\FormRequest;
class UserUpdate extends FormRequest
@ -13,7 +14,7 @@ class UserUpdate extends FormRequest
*/
public function rules()
{
return [
$rules = [
'id' => 'required|integer',
'email' => 'email:strict',
'password' => 'nullable|min:8',
@ -34,11 +35,13 @@ class UserUpdate extends FormRequest
'speed_limit' => 'nullable|integer',
'device_limit' => 'nullable|integer'
];
return HookManager::filter('admin.user.update.rules', $rules, $this);
}
public function messages()
{
return [
$messages = [
'email.required' => '邮箱不能为空',
'email.email' => '邮箱格式不正确',
'transfer_enable.numeric' => '流量格式不正确',
@ -65,5 +68,7 @@ class UserUpdate extends FormRequest
'speed_limit.integer' => '限速格式不正确',
'device_limit.integer' => '设备数量格式不正确'
];
return HookManager::filter('admin.user.update.messages', $messages, $this);
}
}

View File

@ -36,9 +36,7 @@ class OrderHandleJob implements ShouldQueue
*/
public function handle()
{
$order = Order::where('trade_no', $this->tradeNo)
->lockForUpdate()
->first();
$order = Order::where('trade_no', $this->tradeNo)->first();
if (!$order) return;
$orderService = new OrderService($order);
switch ($order->status) {

View File

@ -18,7 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int $total_amount
* @property int|null $handling_amount
* @property int|null $balance_amount
* @property int|null $refund_amount
* @property int|null $surplus_credit
* @property int|null $surplus_amount
* @property int $type
* @property int $status

View File

@ -239,7 +239,7 @@ class General extends AbstractProtocol
$user = $uuid . '@' . Helper::wrapIPv6($host) . ':' . $port;
$query = http_build_query($config);
$fragment = urlencode($name);
$fragment = rawurlencode($name);
$link = sprintf("vless://%s?%s#%s\r\n", $user, $query, $fragment);
return $link;
}

View File

@ -381,6 +381,12 @@ class Shadowrocket extends AbstractProtocol
"obfs" => 'none',
"fastopen" => 1
];
if (($upMbps = data_get($protocol_settings, 'bandwidth.up')) > 0) {
$params['upmbps'] = $upMbps;
}
if (($downMbps = data_get($protocol_settings, 'bandwidth.down')) > 0) {
$params['downmbps'] = $downMbps;
}
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
$params['peer'] = $serverName;
}
@ -411,7 +417,8 @@ class Shadowrocket extends AbstractProtocol
$params = [
'alpn' => data_get($protocol_settings, 'alpn'),
'sni' => data_get($protocol_settings, 'tls.server_name'),
'insecure' => data_get($protocol_settings, 'tls.allow_insecure')
'insecure' => data_get($protocol_settings, 'tls.allow_insecure'),
'congestion_control' => data_get($protocol_settings, 'congestion_control', 'cubic')
];
if (data_get($protocol_settings, 'version') === 4) {
$params['token'] = $password;

View File

@ -162,7 +162,7 @@ class DeviceStateService
}
}
if (!empty($ips)) {
$result[$userId] = array_unique($ips);
$result[$userId] = array_values(array_unique($ips));
}
}
}

View File

@ -15,8 +15,8 @@ use Illuminate\Support\Facades\Log;
class GiftCardService
{
protected readonly GiftCardCode $code;
protected readonly GiftCardTemplate $template;
protected GiftCardCode $code;
protected GiftCardTemplate $template;
protected ?User $user = null;
public function __construct(string $code)
@ -106,6 +106,23 @@ class GiftCardService
}
return DB::transaction(function () use ($options) {
$this->code = GiftCardCode::whereKey($this->code->id)
->lockForUpdate()
->first()
?? throw new ApiException('兑换码不存在');
$this->template = GiftCardTemplate::whereKey($this->code->template_id)
->lockForUpdate()
->first()
?? throw new ApiException('该礼品卡类型不存在');
$this->user = User::whereKey($this->user->id)
->lockForUpdate()
->first()
?? throw new ApiException('用户信息未提供');
$this->validate();
$actualRewards = $this->template->calculateActualRewards($this->user);
if ($this->template->type === GiftCardTemplate::TYPE_MYSTERY) {
@ -198,7 +215,7 @@ class GiftCardService
return null;
}
$inviteUser = User::find($this->user->invite_user_id);
$inviteUser = User::lockForUpdate()->find($this->user->invite_user_id);
if (!$inviteUser) {
return null;
}
@ -212,7 +229,9 @@ class GiftCardService
if (isset($rewards['balance']) && $rewards['balance'] > 0) {
$inviteBalance = intval($rewards['balance'] * $rate);
if ($inviteBalance > 0) {
$userService->addBalance($inviteUser->id, $inviteBalance);
if (!$userService->addBalance($inviteUser->id, $inviteBalance)) {
throw new ApiException('邀请人余额发放失败');
}
$inviteRewards['balance'] = $inviteBalance;
}
}

View File

@ -33,6 +33,21 @@ class OrderService
$this->order = $order;
}
private function lockCurrentOrder(): ?Order
{
return Order::whereKey($this->order->id)
->lockForUpdate()
->first();
}
private function lockCurrentOrderWhenStatus(int $status): ?Order
{
return Order::whereKey($this->order->id)
->where('status', $status)
->lockForUpdate()
->first();
}
/**
* Create an order from a request.
*
@ -56,6 +71,15 @@ class OrderService
HookManager::call('order.create.before', [$user, $plan, $period, $couponCode]);
return DB::transaction(function () use ($user, $plan, $period, $couponCode, $userService) {
$user = User::lockForUpdate()->find($user->id);
if (!$user) {
throw new ApiException(__('The user does not exist'));
}
if ($userService->isNotCompleteOrderByUserId($user->id)) {
throw new ApiException(__('You have an unpaid or pending order, please try again later or cancel it'));
}
$newPeriod = PlanService::getPeriodKey($period);
$order = new Order([
@ -74,12 +98,13 @@ class OrderService
$orderService->setVipDiscount($user);
$orderService->setOrderType($user);
$orderService->setInvite(user: $user);
if ($user->balance && $order->total_amount > 0) {
$orderService->handleUserBalance($user, $userService);
}
$orderService->setInvite(user: $user);
if (!$order->save()) {
throw new ApiException(__('Failed to create order'));
}
@ -94,17 +119,23 @@ class OrderService
public function open(): void
{
$order = $this->order;
$plan = Plan::find($order->plan_id);
$openedOrder = DB::transaction(function () {
$order = $this->lockCurrentOrderWhenStatus(Order::STATUS_PROCESSING);
if (!$order) {
return null;
}
HookManager::call('order.open.before', $order);
$plan = Plan::find($order->plan_id);
if (!$plan) {
throw new \RuntimeException('订阅不存在');
}
HookManager::call('order.open.before', $order);
DB::transaction(function () use ($order, $plan) {
$this->user = User::lockForUpdate()->find($order->user_id);
if ($order->refund_amount) {
$this->user->balance += $order->refund_amount;
if ($order->surplus_credit) {
$this->user->balance += $order->surplus_credit;
}
if ($order->surplus_order_ids) {
@ -129,10 +160,19 @@ class OrderService
if (!$order->save()) {
throw new \RuntimeException('订单信息保存失败');
}
return $order;
});
if (!$openedOrder) {
return;
}
$order = $openedOrder;
$this->order = $order;
$eventId = match ((int) $order->type) {
Order::STATUS_PROCESSING => admin_setting('new_order_event_id', 0),
Order::TYPE_NEW_PURCHASE => admin_setting('new_order_event_id', 0),
Order::TYPE_RENEWAL => admin_setting('renew_order_event_id', 0),
Order::TYPE_UPGRADE => admin_setting('change_order_event_id', 0),
default => 0,
@ -158,7 +198,7 @@ class OrderService
if ((int) admin_setting('surplus_enable', 1))
$this->getSurplusValue($user, $order);
if ($order->surplus_amount >= $order->total_amount) {
$order->refund_amount = (int) ($order->surplus_amount - $order->total_amount);
$order->surplus_credit = (int) ($order->surplus_amount - $order->total_amount);
$order->total_amount = 0;
} else {
$order->total_amount = (int) ($order->total_amount - $order->surplus_amount);
@ -256,7 +296,7 @@ class OrderService
return;
}
$orderAmountSum = $orders->sum(fn($item) => $item->total_amount + $item->balance_amount + $item->surplus_amount - $item->refund_amount);
$orderAmountSum = $orders->sum(fn($item) => $item->total_amount + $item->balance_amount + $item->surplus_amount - $item->surplus_credit);
$orderMonthSum = $orders->sum(fn($item) => self::STR_TO_TIME[PlanService::getPeriodKey($item->period)] ?? 0);
$firstOrderAt = $orders->min('created_at');
$expiredAt = Carbon::createFromTimestamp($firstOrderAt)->addMonths($orderMonthSum);
@ -285,16 +325,31 @@ class OrderService
public function paid(string $callbackNo)
{
$order = $this->order;
if ($order->status !== Order::STATUS_PENDING)
return true;
$order->status = Order::STATUS_PROCESSING;
$order->paid_at = time();
$order->callback_no = $callbackNo;
if (!$order->save())
return false;
try {
OrderHandleJob::dispatchSync($order->trade_no);
[$order, $shouldDispatch] = DB::transaction(function () use ($callbackNo) {
$order = $this->lockCurrentOrder();
if (!$order) {
throw new \RuntimeException('Order not found.');
}
if ((int) $order->status !== Order::STATUS_PENDING) {
return [$order, false];
}
$order->status = Order::STATUS_PROCESSING;
$order->paid_at = time();
$order->callback_no = $callbackNo;
if (!$order->save()) {
throw new \RuntimeException('Failed to save order status.');
}
return [$order, true];
});
$this->order = $order;
if ($shouldDispatch) {
OrderHandleJob::dispatchSync($order->trade_no);
}
} catch (\Exception $e) {
Log::error($e);
return false;
@ -304,25 +359,37 @@ class OrderService
public function cancel(): bool
{
$order = $this->order;
HookManager::call('order.cancel.before', $order);
try {
DB::beginTransaction();
$order->status = Order::STATUS_CANCELLED;
if (!$order->save()) {
throw new \Exception('Failed to save order status.');
}
if ($order->balance_amount) {
$userService = new UserService();
if (!$userService->addBalance($order->user_id, $order->balance_amount)) {
throw new \Exception('Failed to add balance.');
$cancelledOrder = DB::transaction(function () {
$order = $this->lockCurrentOrderWhenStatus(Order::STATUS_PENDING);
if (!$order) {
return null;
}
HookManager::call('order.cancel.before', $order);
$order->status = Order::STATUS_CANCELLED;
if (!$order->save()) {
throw new \RuntimeException('Failed to save order status.');
}
if ($order->balance_amount) {
$userService = new UserService();
if (!$userService->addBalance($order->user_id, $order->balance_amount)) {
throw new \RuntimeException('Failed to add balance.');
}
}
return $order;
});
if (!$cancelledOrder) {
return false;
}
DB::commit();
HookManager::call('order.cancel.after', $order);
$this->order = $cancelledOrder;
HookManager::call('order.cancel.after', $cancelledOrder);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error($e);
return false;
}

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('v2_order', function (Blueprint $table) {
$table->renameColumn('refund_amount', 'surplus_credit');
});
}
public function down(): void
{
Schema::table('v2_order', function (Blueprint $table) {
$table->renameColumn('surplus_credit', 'refund_amount');
});
}
};

View File

@ -102,7 +102,7 @@ class AlipayF2F
'method' => $this->method,
'charset' => 'UTF-8',
'sign_type' => $this->signType,
'timestamp' => date('Y-m-d H:m:s'),
'timestamp' => date('Y-m-d H:i:s'),
'biz_content' => $this->bizContent,
'version' => '1.0',
'_input_charset' => 'UTF-8'

@ -1 +1 @@
Subproject commit fe6dc2952241c6bed807d1ca965e7d5907303265
Subproject commit ef5f43da335092cbff8fdf0ad7ff9b4d92d7d0d7

View File

@ -26,6 +26,7 @@
"No active subscription. Unable to use our provided Apple ID": "No active subscription. Unable to use our provided Apple ID",
"Oops, there's a problem... Please refresh the page and try again later": "Oops, there's a problem... Please refresh the page and try again later",
"Order does not exist": "Order does not exist",
"Order amount is invalid": "Order amount is invalid",
"Order does not exist or has been paid": "Order does not exist or has been paid",
"Payment failed. Please check your credit card information": "Payment failed. Please check your credit card information",
"Payment gateway request failed": "Payment gateway request failed",

View File

@ -26,6 +26,7 @@
"No active subscription. Unable to use our provided Apple ID": "Нет активной подписки. Невозможно использовать предоставленный Apple ID",
"Oops, there's a problem... Please refresh the page and try again later": "Ой, возникла проблема... Обновите страницу и попробуйте позже",
"Order does not exist": "Заказ не существует",
"Order amount is invalid": "Некорректная сумма заказа",
"Order does not exist or has been paid": "Заказ не существует или уже оплачен",
"Payment failed. Please check your credit card information": "Ошибка оплаты. Проверьте данные карты",
"Payment gateway request failed": "Ошибка запроса к платёжному шлюзу",

View File

@ -22,6 +22,7 @@
"Withdrawal account": "提现账号",
"Unsupported withdrawal": "不支持提现",
"Order does not exist": "订单不存在",
"Order amount is invalid": "订单金额异常",
"You have an unpaid or pending order, please try again later or cancel it": "您有未付款或开通中的订单,请稍后再试或将其取消",
"This subscription has been sold out, please choose another subscription": "该订阅已售罄,请更换其它订阅",
"This subscription cannot be renewed, please change to another subscription": "该订阅无法续费,请更换其它订阅",

View File

@ -22,6 +22,7 @@
"Withdrawal account": "提現賬號",
"Unsupported withdrawal": "不支持提現",
"Order does not exist": "訂單不存在",
"Order amount is invalid": "訂單金額異常",
"You have an unpaid or pending order, please try again later or cancel it": "您有未付款或開通中的訂單,請稍後再試或將其取消",
"This subscription has been sold out, please choose another subscription": "該訂閱已售罄,請更換其它訂閱",
"This subscription cannot be renewed, please change to another subscription": "該訂閱無法續費,請更換其它訂閱",

View File

@ -6,7 +6,6 @@ use App\Models\Server;
use App\Models\ServerMachine;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class ServerHandshakeTest extends TestCase
@ -18,7 +17,7 @@ class ServerHandshakeTest extends TestCase
parent::setUp();
config()->set('app.key', 'base64:' . base64_encode(str_repeat('a', 32)));
Cache::forever('admin_settings', [
admin_setting([
'server_token' => 'server-token',
'server_ws_enable' => 0,
]);

9
tests/TestCase.php Normal file
View File

@ -0,0 +1,9 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
}

View File

@ -0,0 +1,193 @@
<?php
namespace Tests\Unit\Services;
use App\Exceptions\ApiException;
use App\Models\GiftCardCode;
use App\Models\GiftCardTemplate;
use App\Models\Order;
use App\Models\Plan;
use App\Models\User;
use App\Services\GiftCardService;
use App\Services\OrderService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class OrderServiceConcurrencyTest extends TestCase
{
use RefreshDatabase;
public function test_cancel_only_refunds_once_when_called_with_stale_order_models(): void
{
$user = $this->makeUser(['balance' => 0]);
$plan = $this->makePlan();
$order = $this->makeOrder($user, $plan, [
'status' => Order::STATUS_PENDING,
'balance_amount' => 100,
'total_amount' => 1000,
]);
$firstStaleOrder = Order::findOrFail($order->id);
$secondStaleOrder = Order::findOrFail($order->id);
$this->assertTrue((new OrderService($firstStaleOrder))->cancel());
$this->assertFalse((new OrderService($secondStaleOrder))->cancel());
$this->assertSame(100, User::findOrFail($user->id)->balance);
}
public function test_open_only_applies_subscription_once_when_called_with_stale_order_models(): void
{
$user = $this->makeUser([
'balance' => 0,
'expired_at' => 0,
'transfer_enable' => 0,
'u' => 1073741824,
'd' => 0,
]);
$plan = $this->makePlan();
$order = $this->makeOrder($user, $plan, [
'status' => Order::STATUS_PROCESSING,
'balance_amount' => 1100,
'total_amount' => 0,
]);
$before = time();
$firstStaleOrder = Order::findOrFail($order->id);
$secondStaleOrder = Order::findOrFail($order->id);
(new OrderService($firstStaleOrder))->open();
(new OrderService($secondStaleOrder))->open();
$user->refresh();
$this->assertSame(1, $user->reset_count);
$this->assertSame(Order::STATUS_COMPLETED, Order::findOrFail($order->id)->status);
$this->assertLessThan($before + (45 * 86400), $user->expired_at);
}
public function test_gift_card_redeem_only_grants_rewards_once_for_stale_code_models(): void
{
$user = $this->makeUser(['balance' => 0]);
$template = GiftCardTemplate::create([
'name' => 'race-test',
'description' => 'race-test',
'type' => GiftCardTemplate::TYPE_GENERAL,
'status' => 1,
'rewards' => ['balance' => 100],
'admin_id' => 1,
'created_at' => time(),
'updated_at' => time(),
]);
GiftCardCode::create([
'template_id' => $template->id,
'code' => 'RACEUNITTEST',
'status' => GiftCardCode::STATUS_UNUSED,
'usage_count' => 0,
'max_usage' => 1,
'created_at' => time(),
'updated_at' => time(),
]);
$firstService = (new GiftCardService('RACEUNITTEST'))->setUser(User::findOrFail($user->id));
$secondService = (new GiftCardService('RACEUNITTEST'))->setUser(User::findOrFail($user->id));
$firstService->validate();
$secondService->validate();
$firstService->redeem();
try {
$secondService->redeem();
$this->fail('The second stale gift card redeem should fail.');
} catch (ApiException $exception) {
$this->assertNotSame('', $exception->getMessage());
}
$user->refresh();
$code = GiftCardCode::where('code', 'RACEUNITTEST')->firstOrFail();
$this->assertSame(100, $user->balance);
$this->assertSame(1, $code->usage_count);
$this->assertSame(1, $code->usages()->count());
}
public function test_checkout_rejects_negative_order_amount(): void
{
$user = $this->makeUser(['balance' => 0]);
Sanctum::actingAs($user);
$order = $this->makeOrder($user, $this->makePlan(), [
'status' => Order::STATUS_PENDING,
'total_amount' => -1,
]);
$response = $this->postJson('/api/v1/user/order/checkout', [
'trade_no' => $order->trade_no,
]);
$response->assertStatus(400);
$this->assertSame(Order::STATUS_PENDING, Order::findOrFail($order->id)->status);
$this->assertNull(User::findOrFail($user->id)->plan_id);
}
private function makeUser(array $overrides = []): User
{
return User::create(array_merge([
'email' => 'race-test@example.com',
'password' => 'password',
'uuid' => '00000000-0000-0000-0000-000000000001',
'token' => '0123456789abcdef0123456789abcdef',
'balance' => 0,
'commission_balance' => 0,
'transfer_enable' => 0,
'u' => 0,
'd' => 0,
'banned' => 0,
'is_admin' => 0,
'is_staff' => 0,
'expired_at' => 0,
'remind_expire' => 1,
'remind_traffic' => 1,
'created_at' => time(),
'updated_at' => time(),
], $overrides));
}
private function makePlan(array $overrides = []): Plan
{
return Plan::create(array_merge([
'group_id' => null,
'transfer_enable' => 1111,
'name' => 'Race Test Plan',
'speed_limit' => null,
'show' => 1,
'sort' => 0,
'renew' => 1,
'prices' => [
Plan::PERIOD_MONTHLY => 11,
],
'reset_traffic_method' => Plan::RESET_TRAFFIC_MONTHLY,
'capacity_limit' => null,
'sell' => 1,
'device_limit' => null,
'created_at' => time(),
'updated_at' => time(),
], $overrides));
}
private function makeOrder(User $user, Plan $plan, array $overrides = []): Order
{
return Order::create(array_merge([
'user_id' => $user->id,
'plan_id' => $plan->id,
'type' => Order::TYPE_NEW_PURCHASE,
'period' => Plan::PERIOD_MONTHLY,
'trade_no' => uniqid('race_', true),
'total_amount' => 0,
'balance_amount' => 0,
'status' => Order::STATUS_PENDING,
'commission_status' => 0,
'commission_balance' => 0,
'created_at' => time(),
'updated_at' => time(),
], $overrides));
}
}