|
|
@@ -8,6 +8,9 @@ use App\Models\BalanceLog;
|
|
|
use App\Services\PromotionAdminService;
|
|
|
use App\Services\PromotionFacts;
|
|
|
use App\Services\PromotionService;
|
|
|
+use App\Services\TelegramFirstDeposit;
|
|
|
+use App\Models\User;
|
|
|
+use App\Support\FirstDepositChoice;
|
|
|
use App\Support\PromotionRules;
|
|
|
use Carbon\Carbon;
|
|
|
use Illuminate\Config\Repository;
|
|
|
@@ -56,6 +59,8 @@ class PromotionActivitiesTest extends TestCase
|
|
|
] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
|
|
|
foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php', '2026_11_03_120000_add_promotion_coupons.php', '2026_11_03_170000_repeat_loss_invite_rounds.php', '2026_11_04_120000_create_upgrade_invite_promotion.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
|
|
|
(require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_180000_allow_permanent_activities.php')->up();
|
|
|
+ (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_120000_add_first_deposit_join_choice.php')->up();
|
|
|
+ (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_130000_bind_first_deposit_choices.php')->up();
|
|
|
$this->member(1); $this->member(2, 1);
|
|
|
DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
|
|
|
$this->service = new PromotionService();
|
|
|
@@ -82,6 +87,18 @@ class PromotionActivitiesTest extends TestCase
|
|
|
'vi' => ['title' => 'Khuyến mãi', 'content' => '<p>Nội dung</p>']];
|
|
|
}
|
|
|
|
|
|
+ /** 会员在首笔充值前勾选参加首充活动。 */
|
|
|
+ private function join(int $id, $value = 1): bool
|
|
|
+ {
|
|
|
+ $joined = FirstDepositChoice::parse($value);
|
|
|
+ $offer = null;
|
|
|
+ if ($joined) {
|
|
|
+ $activity = $this->service->listing($id, 'H5', 'zh', 8)['data'][0];
|
|
|
+ $offer = FirstDepositChoice::offer($activity['activity_id'], 'H5', $activity['rules'], $activity['start_time'], $activity['end_time']);
|
|
|
+ }
|
|
|
+ return FirstDepositChoice::record(User::find($id), $joined, 'test', $offer);
|
|
|
+ }
|
|
|
+
|
|
|
private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
|
|
|
{
|
|
|
return BalanceLog::create(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time ?? PromotionFacts::now()]);
|
|
|
@@ -134,7 +151,7 @@ class PromotionActivitiesTest extends TestCase
|
|
|
}
|
|
|
$this->assertSame(0, PromotionAdminService::expire());
|
|
|
$id = (int)DB::table('promotion_activities')->where('type', 8)->value('id');
|
|
|
- $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
+ $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
$this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
|
|
|
}
|
|
|
|
|
|
@@ -312,7 +329,7 @@ class PromotionActivitiesTest extends TestCase
|
|
|
public function test_first_deposit_translation_does_not_split_or_duplicate_awards(): void
|
|
|
{
|
|
|
$id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
|
|
|
- DB::table('users')->where('id', 1)->update(['language' => 'vi']);
|
|
|
+ DB::table('users')->where('id', 1)->update(['language' => 'vi']); $this->join(1);
|
|
|
$deposit = $this->deposit(1, '100'); $this->service->onDeposit($deposit);
|
|
|
$this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
|
|
|
DB::table('users')->where('id', 1)->update(['language' => 'en']);
|
|
|
@@ -428,7 +445,7 @@ class PromotionActivitiesTest extends TestCase
|
|
|
|
|
|
public function test_first_deposit_is_historical_first_and_does_not_refill_on_relisting(): void
|
|
|
{
|
|
|
- $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']);
|
|
|
+ $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']); $this->join(1); $this->join(2);
|
|
|
$first = $this->deposit(1, '99'); $this->service->onDeposit($first); $this->service->onDeposit($first);
|
|
|
$second = $this->deposit(1, '1000'); $this->service->onDeposit($second);
|
|
|
$this->assertSame(0, DB::table('promotion_rewards')->count());
|
|
|
@@ -573,7 +590,7 @@ class PromotionActivitiesTest extends TestCase
|
|
|
{
|
|
|
// 活动奖金不再冻结:直接进入可用余额,流水要求计入统一提现打码量(required_flow 仍保留在奖励记录上)
|
|
|
$id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'turnover_ratio' => 2]);
|
|
|
- $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
+ $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
$reward = DB::table('promotion_rewards')->where('activity_id', $id)->first();
|
|
|
$this->assertSame('paid', $reward->status);
|
|
|
$this->assertSame(0, bccomp('50', (string)$reward->required_flow, 2));
|
|
|
@@ -772,7 +789,7 @@ class PromotionActivitiesTest extends TestCase
|
|
|
'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,status INTEGER,amount TEXT,created_at TEXT,updated_at TEXT',
|
|
|
] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
|
|
|
foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
|
|
|
- $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
|
|
|
+ $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); $this->join(1); $this->join(2);
|
|
|
$source = DB::table('recharges')->insertGetId(['member_id' => 'm1', 'status' => 0, 'amount' => 100]);
|
|
|
$recharge = \App\Models\Recharge::find($source);
|
|
|
$this->assertTrue(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
|
|
|
@@ -821,7 +838,7 @@ class PromotionActivitiesTest extends TestCase
|
|
|
'payment_orders' => 'id INTEGER PRIMARY KEY,member_id TEXT,status INTEGER,type INTEGER,amount TEXT,remark TEXT,state INTEGER,created_at TEXT,updated_at TEXT',
|
|
|
] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
|
|
|
foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
|
|
|
- $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
|
|
|
+ $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); $this->join(1);
|
|
|
DB::table('payment_orders')->insert(['id' => 1, 'member_id' => 'm1', 'status' => 5, 'type' => 3, 'amount' => '100']);
|
|
|
$this->app->instance('request', Request::create('/admin/paymentOrder/manualAudit', 'POST', ['id' => 1, 'status' => 2]));
|
|
|
$body = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true);
|
|
|
@@ -905,4 +922,141 @@ class PromotionActivitiesTest extends TestCase
|
|
|
$this->invalid(fn () => $admin->records(['activity_id' => 99999, 'kind' => 'rewards']), '独立活动不存在');
|
|
|
$this->assertSame(0, $admin->records(['activity_id' => $loss, 'type' => 7, 'kind' => 'rewards'])['total']);
|
|
|
}
|
|
|
+
|
|
|
+ public function test_first_deposit_requires_opt_in_and_declining_consumes_the_first_deposit(): void
|
|
|
+ {
|
|
|
+ $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
|
|
|
+ // 默认不勾选:没有记录选择的会员首笔到账不发奖,第二笔起也不再算首充。
|
|
|
+ $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
+ $this->invalid(fn () => $this->join(1), '已有充值记录,不能再选择首充活动');
|
|
|
+ $this->service->onDeposit($this->deposit(1, '1000'));
|
|
|
+ $this->assertSame(0, DB::table('promotion_rewards')->count());
|
|
|
+ $this->assertSame(1, DB::table('promotion_first_deposits')->where('user_id', 1)->count());
|
|
|
+ // 明确选择不参加后又改为参加:以首笔到账前最后一次选择为准。
|
|
|
+ $this->assertTrue($this->join(2, '0')); $this->assertTrue($this->join(2, true));
|
|
|
+ $this->assertSame(1, (int)DB::table('promotion_first_deposit_choices')->where('user_id', 2)->value('joined'));
|
|
|
+ $this->assertFalse($this->join(2, null));
|
|
|
+ $this->service->onDeposit($this->deposit(2, '100'));
|
|
|
+ $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->where('user_id', 2)->count());
|
|
|
+ // 首笔到账后选择不能再修改。
|
|
|
+ $this->assertFalse($this->join(2, 0));
|
|
|
+ $this->assertSame(1, (int)DB::table('promotion_first_deposit_choices')->where('user_id', 2)->value('joined'));
|
|
|
+ $this->member(3); $this->join(3, 0);
|
|
|
+ $this->service->onDeposit($this->deposit(3, '100')); $this->service->onDeposit($this->deposit(3, '500'));
|
|
|
+ $this->assertSame(0, DB::table('promotion_rewards')->where('user_id', 3)->count());
|
|
|
+ $this->invalid(fn () => \App\Support\FirstDepositChoice::parse('yes'), 'join_first_deposit只能为0或1');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_first_deposit_forbidden_categories_use_commission_categories_and_show_in_admin_list(): void
|
|
|
+ {
|
|
|
+ $admin = new PromotionAdminService();
|
|
|
+ $options = $admin->options();
|
|
|
+ $this->assertSame(array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories()), array_column($options['forbidden_categories'], 'value'));
|
|
|
+ $this->assertSame([], $options['defaults'][8]['forbidden_categories']);
|
|
|
+ $this->assertNotContains('third:3', array_column($options['forbidden_categories'], 'value'));
|
|
|
+ $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'forbidden_categories' => ['self:sport', 'third:2', 'third:1']]);
|
|
|
+ $detail = $admin->detail($id);
|
|
|
+ $this->assertSame(['third:1', 'third:2', 'self:sport'], $detail['params']['forbidden_categories']);
|
|
|
+ $this->assertSame([['value' => 'third:1', 'label' => '真人'], ['value' => 'third:2', 'label' => '电子'], ['value' => 'self:sport', 'label' => 'F1体育']], $detail['forbidden_categories']);
|
|
|
+ $row = $admin->listing(['type' => 8])['data'][0];
|
|
|
+ $this->assertSame(['真人', '电子', 'F1体育'], array_column($row['forbidden_categories'], 'label'));
|
|
|
+ // 局部编辑其他规则时保留禁止大类,传空数组清空。
|
|
|
+ $base = ['id' => $id, 'type' => 8, 'status' => 1, 'translations' => $this->texts()];
|
|
|
+ $this->assertSame(['third:1', 'third:2', 'self:sport'], $admin->save($base + ['params' => ['reward_amount' => 30]])['params']['forbidden_categories']);
|
|
|
+ foreach ([['third:3'], ['third:1', 'third:1'], 'third:1', ['x' => 'third:1'], [4]] as $bad) {
|
|
|
+ $this->invalid(fn () => $admin->save($base + ['params' => ['forbidden_categories' => $bad]]), 'forbidden_categories参数错误');
|
|
|
+ }
|
|
|
+ $this->assertSame([], $admin->save($base + ['params' => ['forbidden_categories' => []]])['forbidden_categories']);
|
|
|
+ // 旧配置没有该键时按不限制返回。
|
|
|
+ DB::table('promotion_activities')->where('id', $id)->update(['params' => json_encode(['min_deposit' => '100.00', 'reward_amount' => '25.00', 'turnover_ratio' => '0.00'])]);
|
|
|
+ $this->assertSame([], $admin->detail($id)['params']['forbidden_categories']);
|
|
|
+ // 发奖规则快照带上禁止大类,供会员端下注时判断。
|
|
|
+ $admin->save($base + ['params' => ['forbidden_categories' => ['third:5'], 'turnover_ratio' => 2]]);
|
|
|
+ $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
+ $snapshot = json_decode(DB::table('promotion_rewards')->where('user_id', 1)->value('rule_snapshot'), true);
|
|
|
+ $this->assertSame(['third:5'], $snapshot['forbidden_categories']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_first_deposit_pays_the_confirmed_campaign_and_terms(): void
|
|
|
+ {
|
|
|
+ $admin = new PromotionAdminService();
|
|
|
+ $base = ['type' => 8, 'status' => 1, 'translations' => $this->texts()];
|
|
|
+ $h5 = $admin->save($base + ['sort' => 1, 'publish_platforms' => ['H5'], 'params' => ['reward_amount' => 25, 'turnover_ratio' => 2]])['id'];
|
|
|
+ $admin->save($base + ['sort' => 2, 'publish_platforms' => ['PC'], 'params' => [
|
|
|
+ 'reward_amount' => 80, 'turnover_ratio' => 2, 'forbidden_categories' => ['self:lottery'],
|
|
|
+ ]]);
|
|
|
+ $shown = $this->service->listing(1, 'H5', 'zh', 8)['data'][0];
|
|
|
+ $offer = FirstDepositChoice::offer($shown['activity_id'], 'H5', $shown['rules'], null, null);
|
|
|
+ $this->assertTrue(FirstDepositChoice::record(User::find(1), true, 'test', $offer));
|
|
|
+ $admin->save($base + ['id' => $h5, 'params' => ['reward_amount' => 50, 'forbidden_categories' => ['self:sport']]]);
|
|
|
+ $this->invalid(fn () => FirstDepositChoice::record(User::find(2), true, 'test', $offer), '首充活动已变更,请刷新后重新确认');
|
|
|
+ $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
+ $reward = DB::table('promotion_rewards')->where('user_id', 1)->first();
|
|
|
+ $this->assertSame($h5, (int) $reward->activity_id);
|
|
|
+ $this->assertSame('25.00', json_decode($reward->rule_snapshot, true)['reward_amount']);
|
|
|
+ $this->assertSame([], json_decode($reward->rule_snapshot, true)['forbidden_categories']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_pending_order_cutover_is_source_specific_and_keeps_legacy_terms(): void
|
|
|
+ {
|
|
|
+ $id = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2, 'forbidden_categories' => ['self:lottery']]);
|
|
|
+ DB::statement('CREATE TABLE bot_payment_orders (id INTEGER PRIMARY KEY)');
|
|
|
+ DB::table('payment_orders')->insert(['id' => 1]);
|
|
|
+ $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_130000_bind_first_deposit_choices.php';
|
|
|
+ $migration->down();
|
|
|
+ $migration->up();
|
|
|
+ DB::table('payment_orders')->insert(['id' => 2]);
|
|
|
+ $migration->up();
|
|
|
+
|
|
|
+ $old = $this->deposit(1, '100', type: '三方充值');
|
|
|
+ $old->related_id = 1;
|
|
|
+ $old->save();
|
|
|
+ $this->service->onDeposit($old, 'payment_orders');
|
|
|
+ $new = $this->deposit(2, '100', type: '三方充值');
|
|
|
+ $new->related_id = 2;
|
|
|
+ $new->save();
|
|
|
+ $this->service->onDeposit($new, 'payment_orders');
|
|
|
+ $this->member(3);
|
|
|
+ $otherSource = $this->deposit(3, '100');
|
|
|
+ $otherSource->related_id = 1;
|
|
|
+ $otherSource->save();
|
|
|
+ $this->service->onDeposit($otherSource, 'recharges');
|
|
|
+
|
|
|
+ $this->assertSame([1], DB::table('promotion_rewards')->where('activity_id', $id)->pluck('user_id')->all());
|
|
|
+ $this->assertSame([], json_decode(DB::table('promotion_rewards')->value('rule_snapshot'), true)['forbidden_categories']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_first_deposit_with_turnover_cannot_forbid_every_category(): void
|
|
|
+ {
|
|
|
+ $all = array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories());
|
|
|
+ $rules = ['turnover_ratio' => 2, 'forbidden_categories' => $all];
|
|
|
+ $error = '首充奖励有打码量时,至少保留一个可投注的游戏大类';
|
|
|
+ $this->invalid(fn () => $this->activity(8, $rules), $error);
|
|
|
+ $draft = $this->activity(8, [], 0);
|
|
|
+ DB::table('promotion_activities')->where('id', $draft)->update(['params' => json_encode($rules)]);
|
|
|
+ $this->invalid(fn () => (new PromotionAdminService())->status($draft, 1), $error);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_telegram_choice_is_bound_to_the_member_and_its_restriction_lifts_after_turnover(): void
|
|
|
+ {
|
|
|
+ \Illuminate\Support\Facades\Cache::swap(new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore()));
|
|
|
+ $activity = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2, 'forbidden_categories' => ['self:sport']]);
|
|
|
+ $lotteryForbidden = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2, 'forbidden_categories' => ['self:lottery']]);
|
|
|
+ $member = User::find(1);
|
|
|
+ $reply = TelegramFirstDeposit::menu($member);
|
|
|
+ $button = json_decode($reply['reply_markup'], true)['inline_keyboard'][0][0]['callback_data'];
|
|
|
+ TelegramFirstDeposit::callback(User::find(2), $button);
|
|
|
+ $this->assertNull(FirstDepositChoice::selection(2));
|
|
|
+ TelegramFirstDeposit::callback($member, $button);
|
|
|
+ $this->assertSame($activity, (int) FirstDepositChoice::selection(1)->activity_id);
|
|
|
+ $this->service->onDeposit($this->deposit(1, '100'));
|
|
|
+ $this->assertFalse($this->service->lotteryRestricted('m1'));
|
|
|
+
|
|
|
+ $this->assertTrue($this->join(2));
|
|
|
+ $this->assertSame($lotteryForbidden, (int) FirstDepositChoice::selection(2)->activity_id);
|
|
|
+ $this->service->onDeposit($this->deposit(2, '100'));
|
|
|
+ $this->assertTrue($this->service->lotteryRestricted('m2'));
|
|
|
+ $this->bet(2, '50', '-50', PromotionFacts::now());
|
|
|
+ $this->assertFalse($this->service->lotteryRestricted('m2'));
|
|
|
+ }
|
|
|
}
|