| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- <?php
- namespace Tests\Integration;
- use App\Http\Controllers\admin\Agent as AdminAgent;
- use App\Models\Agent\Agent;
- use App\Models\Agent\AgentEquityOrder;
- use App\Models\Agent\AgentProfitCommissionProfile;
- use App\Models\Agent\AgentProfitEntry;
- use App\Models\Agent\AgentRebateRecord;
- use App\Models\Agent\AgentRecharge;
- use App\Models\Agent\AgentSetting;
- use App\Models\Agent\AgentWithdrawAccount;
- use App\Services\Agent\AgentCommissionService;
- use App\Services\Agent\AgentEquityService;
- use App\Services\Agent\AgentPlanApplicationService;
- use App\Services\Agent\AgentProfitCommissionAssignmentService;
- use App\Services\Agent\AgentProfitLedgerService;
- use App\Services\Agent\AgentRechargeService;
- use App\Services\Agent\AgentWalletService;
- use App\Services\Agent\AgentWithdrawalService;
- use App\Services\MemberDailyReportService;
- use App\Services\Sharing\ShareDefaults;
- use Carbon\Carbon;
- use Illuminate\Http\Request;
- use Illuminate\Support\Facades\DB;
- use PHPUnit\Framework\TestCase;
- use Tests\Support\AgentTestEnvironment;
- class AgentJointVentureTest extends TestCase
- {
- use AgentTestEnvironment;
- private AgentWalletService $wallets;
- private AgentProfitLedgerService $ledger;
- private AgentRechargeService $recharges;
- private AgentPlanApplicationService $plans;
- private function services(): void
- {
- $assignments = new AgentProfitCommissionAssignmentService();
- $this->wallets = new AgentWalletService();
- $this->ledger = new AgentProfitLedgerService($this->wallets, $assignments);
- $this->recharges = new AgentRechargeService($this->wallets, new AgentEquityService($this->wallets));
- $this->plans = new AgentPlanApplicationService($assignments, $this->wallets);
- DB::table('config')->insert([['field' => 'receiving_type', 'val' => '2'], ['field' => 'exchange_rate_rmb', 'val' => '7'],
- ['field' => 'exchange_rate_difference', 'val' => '0.5'], ['field' => 'receiving_address', 'val' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE']]);
- }
- private function profile(string $margin = '7000', string $rate = '10'): AgentProfitCommissionProfile
- {
- $values = ['name' => 'Plan ' . $margin, 'margin_amount' => $margin];
- foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) $values[$field] = $rate;
- return AgentProfitCommissionProfile::query()->create($values);
- }
- /** 合营代理:申请方案并审核通过,再充值 $usdt USDT(汇率 7)。 */
- private function jointAgent(AgentProfitCommissionProfile $profile, string $usdt): Agent
- {
- $agent = $this->agent();
- $application = $this->plans->apply($agent, (int) $profile->id);
- $this->plans->audit((int) $application->id, 'approved', 1);
- $this->recharge($agent, $usdt);
- return $agent->fresh();
- }
- private function recharge(Agent $agent, string $usdt): void
- {
- $record = $this->recharges->submitProof($agent, ['net' => 'trc20', 'usdt_amount' => $usdt,
- 'to_address' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'image' => 'https://example.test/proof.png']);
- $this->recharges->audit((int) $record->id, AgentRecharge::STATUS_SUCCESS, 1);
- }
- private function member(Agent $agent): int
- {
- static $number = 0;
- return (int) DB::table('users')->insertGetId(['member_id' => 'jv' . ++$number, 'username' => 'jv' . $number,
- 'agent_id' => $agent->id, 'created_at' => now()]);
- }
- private function fact(int $userId, string $net, string $category = 'self:lottery', string $settledAt = '2026-09-07 13:00:00'): object
- {
- $id = DB::table('share_bet_facts')->insertGetId(['source' => 'test', 'source_id' => uniqid('', true), 'user_id' => $userId,
- 'category' => $category, 'bet_at' => $settledAt, 'bet_date' => substr($settledAt, 0, 10), 'settled_at' => $settledAt,
- 'bet_amount' => 100, 'effective_amount' => 100, 'net_profit' => $net, 'settled' => 1, 'voided' => 0,
- 'created_at' => now(), 'updated_at' => now()]);
- return DB::table('share_bet_facts')->find($id);
- }
- private function process(object $fact, Agent $agent): string
- {
- return $this->ledger->process($fact, (int) $agent->id);
- }
- public function test_recharge_becomes_margin_and_activates_plan(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('7000'), '999');
- $this->assertSame('6993.0000', $agent->balance);
- $this->assertSame('6993.0000', $agent->margin_amount);
- $this->assertSame(0, $agent->profit_active, 'below the plan margin the plan stays inactive');
- $this->recharge($agent, '1');
- $agent = $agent->fresh();
- $this->assertSame('7000.0000', $agent->margin_amount);
- $this->assertSame(1, $agent->profit_active);
- $this->assertSame('0.0000', $this->wallets->withdrawable($agent), 'margin is never withdrawable');
- $this->assertSame('0', DB::table('recharges')->count() . '', 'agent recharges never land in member recharges');
- }
- public function test_share_is_two_sided_and_daily_credit_keeps_realtime_invariant(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
- $userId = $this->member($agent);
- $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent));
- $this->assertSame('counted', $this->process($this->fact($userId, '500', 'third:1'), $agent));
- $agent = $agent->fresh();
- $this->assertSame('6960.0000', $agent->realtime_balance);
- $this->assertSame('7000.0000', $agent->balance, 'balance only moves at the daily credit');
- Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
- $result = (new AgentCommissionService($this->wallets))->settle('2026-09-07');
- $this->assertSame(['created' => 2, 'credited' => 2], $result);
- $agent = $agent->fresh();
- $this->assertSame('6960.0000', $agent->balance);
- $this->assertSame('6960.0000', $agent->realtime_balance);
- $this->assertSame('-40.0000', bcadd((string) AgentRebateRecord::query()->sum('profit_commission'), '0', 4));
- $this->assertSame(0, AgentProfitEntry::query()->whereNull('rebate_record_id')->count());
- $this->assertSame(['created' => 0, 'credited' => 0], (new AgentCommissionService($this->wallets))->settle('2026-09-07'));
- }
- public function test_dropping_below_stop_percent_skips_later_bets_until_margin_is_refilled(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
- $userId = $this->member($agent);
- // Member wins 64000: agent share -6400, realtime margin 600 < 10% of 7000.
- $this->process($this->fact($userId, '64000'), $agent);
- $agent = $agent->fresh();
- $this->assertSame('600.0000', $agent->realtime_balance);
- $this->assertSame(0, $agent->profit_active);
- $this->assertSame('skipped', $this->process($this->fact($userId, '-1000'), $agent));
- $this->assertSame('600.0000', $agent->fresh()->realtime_balance);
- $this->recharge($agent, '900'); // +6300: realtime 6900 < 7000, still stopped
- $this->assertSame(0, $agent->fresh()->profit_active);
- $this->recharge($agent, '100'); // realtime 7600 >= 7000
- $agent = $agent->fresh();
- $this->assertSame(1, $agent->profit_active);
- $this->assertSame('14000.0000', $agent->margin_amount);
- $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent));
- }
- public function test_loss_is_capped_at_zero_balance(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('0', '50'), '10'); // 70 margin, no margin requirement
- $userId = $this->member($agent);
- $this->process($this->fact($userId, '1000'), $agent);
- $this->assertSame('0.0000', $agent->fresh()->realtime_balance);
- $this->assertSame('-70.0000', AgentProfitEntry::query()->value('share_amount'));
- }
- public function test_changed_payout_is_corrected_in_place_then_by_revision_after_credit(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
- $userId = $this->member($agent);
- $fact = $this->fact($userId, '-100');
- $this->process($fact, $agent);
- DB::table('share_bet_facts')->where('id', $fact->id)->update(['net_profit' => -300]);
- $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent));
- $this->assertSame(1, AgentProfitEntry::query()->count());
- $this->assertSame('30.0000', AgentProfitEntry::query()->value('share_amount'));
- Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
- (new AgentCommissionService($this->wallets))->settle('2026-09-07');
- DB::table('share_bet_facts')->where('id', $fact->id)->update(['voided' => 1]);
- $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent));
- $revision = AgentProfitEntry::query()->where('revision', 1)->firstOrFail();
- $this->assertSame('-30.0000', $revision->share_amount);
- $this->assertNull($revision->rebate_record_id);
- $this->assertSame('7000.0000', $agent->fresh()->realtime_balance);
- }
- public function test_only_profit_above_margin_is_withdrawable_and_pending_losses_are_reserved(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
- $userId = $this->member($agent);
- Agent::query()->whereKey($agent->id)->update(['withdraw_password' => \Illuminate\Support\Facades\Hash::make('pw')]);
- $this->process($this->fact($userId, '-15000'), $agent); // +1500
- Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
- (new AgentCommissionService($this->wallets))->settle('2026-09-07');
- $agent = $agent->fresh();
- $this->assertSame('1500.0000', $this->wallets->withdrawable($agent));
- // Today's pending loss (-500) reduces what may leave now.
- $this->process($this->fact($userId, '5000', 'self:lottery', '2026-09-08 01:00:00'), $agent);
- $agent = $agent->fresh();
- $this->assertSame('1000.0000', $this->wallets->withdrawable($agent));
- $account = AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
- 'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);
- $service = new AgentWithdrawalService($this->wallets);
- $withdrawal = $service->request($agent, $account->id, '100', 'pw', 'ok'); // 100 USDT * 7.5
- $this->assertSame('750.0000', $withdrawal->amount);
- $this->assertSame('7.5000', $withdrawal->exchange_rate);
- $this->expectExceptionMessage('可提现盈利不足');
- $service->request($agent->fresh(), $account->id, '100', 'pw', 'too-much');
- }
- public function test_equity_order_waits_for_recharge_then_opens_without_review(): void
- {
- $this->services();
- AgentSetting::current()->update(['equity_enabled' => 1, 'equity_price_per_percent' => 70000,
- 'equity_min_percent' => 0.1, 'equity_max_percent' => 1]);
- $agent = $this->agent(Agent::TYPE_EQUITY);
- $equity = new AgentEquityService($this->wallets);
- $order = $equity->order($agent, '0.5');
- $this->assertSame(AgentEquityOrder::STATUS_PENDING, $order->status);
- $this->assertSame('35000.0000', $order->amount);
- $this->recharge($agent, '5000');
- $agent = $agent->fresh();
- $this->assertSame('0.5000', $agent->equity_percent);
- $this->assertSame('0.0000', $agent->balance);
- $this->assertSame('0.0000', $agent->margin_amount, 'equity recharges are not margin');
- $this->assertSame(AgentEquityOrder::STATUS_COMPLETED, $order->fresh()->status);
- $this->expectExceptionMessage('累计持股不能超过');
- $equity->order($agent, '0.6');
- }
- public function test_admin_create_ignores_removed_hierarchy_and_plan_fields(): void
- {
- $request = Request::create('/admin/agent/create', 'POST', ['username' => 'newagent', 'password' => 'secret1',
- 'type' => 2, 'parent_id' => 99, 'owner_user_id' => 5, 'flow_commission_profile_id' => 1,
- 'profit_commission_profile_id' => 1]);
- $request->user = (object) ['id' => 1];
- $this->app->instance('request', $request);
- $data = (new AdminAgent())->store($request, $this->agents)->getData(true);
- $this->assertSame(0, $data['code'], (string) $data['msg']);
- $agent = Agent::query()->where('username', 'newagent')->firstOrFail();
- $this->assertSame(Agent::TYPE_EQUITY, $agent->type);
- $this->assertNull($agent->parent_id);
- $this->assertNull($agent->owner_user_id);
- $this->assertNull($agent->profit_commission_profile_id);
- $this->assertSame('入股', $agent->type_text);
- $this->assertArrayNotHasKey('realtime_balance', $agent->toArray());
- }
- public function test_member_daily_report_aggregates_bets_funds_and_agent_share(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('0', '10'), '10');
- $userId = $this->member($agent);
- $memberId = DB::table('users')->where('id', $userId)->value('member_id');
- $this->process($this->fact($userId, '-100'), $agent);
- $this->fact($userId, '40', 'third:2');
- DB::table('recharges')->insert(['member_id' => $memberId, 'status' => 1, 'amount' => 300, 'created_at' => '2026-09-07 09:00:00']);
- $service = new MemberDailyReportService();
- $this->assertSame(1, $service->refresh('2026-09-07'));
- $row = DB::table('member_daily_reports')->first();
- $this->assertSame('300.0000', bcadd((string) $row->deposit_amount, '0', 4));
- $this->assertSame('-60.0000', bcadd((string) $row->win_loss, '0', 4));
- $this->assertSame('10.0000', bcadd((string) $row->agent_share, '0', 4));
- $this->assertSame(2, (int) $row->bet_count);
- $this->assertSame(1, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $agent)['total']);
- $this->assertSame(0, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $this->agent())['total']);
- }
- public function test_sync_reads_new_facts_once_and_ignores_non_joint_members(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('0', '10'), '10');
- $this->fact($this->member($agent), '-100');
- $this->fact($this->member($this->agent(Agent::TYPE_EQUITY)), '-100');
- $this->fact((int) DB::table('users')->insertGetId(['member_id' => 'plain', 'username' => 'plain']), '-100');
- $first = $this->ledger->sync();
- $this->assertSame(1, $first['counted']);
- $this->assertSame(0, $first['failed']);
- $this->assertSame(1, $this->ledger->sync()['unchanged'], 'overlap window re-reads the fact without double counting');
- $this->assertSame('80.0000', $agent->fresh()->realtime_balance);
- }
- public function test_agent_with_a_plan_cannot_switch_and_can_reapply_after_rejection(): void
- {
- $this->services();
- $first = $this->profile('7000');
- $second = $this->profile('14000');
- $agent = $this->agent();
- $rejected = $this->plans->apply($agent, (int) $second->id);
- $this->plans->audit((int) $rejected->id, 'rejected', 1, 'no');
- $this->plans->audit((int) $this->plans->apply($agent, (int) $first->id)->id, 'approved', 1);
- $this->assertSame('合营', $this->plans->paginate([])['list']->first()->agent->type_text);
- $this->expectExceptionMessage('已有分成方案,不能切换');
- $this->plans->apply($agent->fresh(), (int) $second->id);
- }
- public function test_proof_must_target_platform_address(): void
- {
- $this->services();
- $this->expectExceptionMessage('收款地址不是平台当前的收款地址');
- $this->recharges->submitProof($this->agent(), ['net' => 'trc20', 'usdt_amount' => '10',
- 'to_address' => 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf', 'image' => 'https://example.test/p.png']);
- }
- public function test_correction_goes_to_the_agent_that_received_the_share(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
- $other = $this->jointAgent($this->profile('0', '10'), '10');
- $userId = $this->member($agent);
- $fact = $this->fact($userId, '-100');
- $this->ledger->sync();
- DB::table('users')->where('id', $userId)->update(['agent_id' => $other->id]);
- DB::table('share_bet_facts')->where('id', $fact->id)->update(['net_profit' => -300, 'updated_at' => now()]);
- $this->assertSame(1, $this->ledger->sync()['corrected']);
- $this->assertSame('7030.0000', $agent->fresh()->realtime_balance);
- $this->assertSame('70.0000', $other->fresh()->realtime_balance);
- }
- public function test_debit_cannot_leave_an_earlier_pending_loss_uncovered(): void
- {
- $this->services();
- $agent = $this->jointAgent($this->profile('0', '10'), '100'); // balance 700
- $userId = $this->member($agent);
- $this->process($this->fact($userId, '6000', 'self:lottery', '2026-09-07 13:00:00'), $agent); // -600 yesterday
- $this->process($this->fact($userId, '-5000', 'self:lottery', '2026-09-08 01:00:00'), $agent); // +500 today
- $this->assertSame('600.0000', $agent->fresh()->realtime_balance);
- try {
- $this->wallets->adjust((int) $agent->id, '200', 'debit', 't', 'debit-1', 'admin', 1);
- $this->fail('crediting yesterday after the debit would leave a negative balance');
- } catch (\RuntimeException $e) {
- $this->assertSame('代理余额不足', $e->getMessage());
- }
- $this->wallets->adjust((int) $agent->id, '100', 'debit', 't', 'debit-2', 'admin', 1);
- Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
- (new AgentCommissionService($this->wallets))->settle('2026-09-07');
- $this->assertSame('0.0000', $agent->fresh()->balance);
- }
- public function test_commission_categories_drop_third_party_lottery(): void
- {
- $this->assertCount(8, ShareDefaults::commissionCategories());
- $this->assertArrayNotHasKey('third:3', ShareDefaults::commissionCategories());
- $this->assertSame(array_keys(ShareDefaults::commissionCategories()), array_keys(array_intersect_key(
- ShareDefaults::commissionCategories(), AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS)));
- $this->assertCount(8, AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS);
- $rules = json_decode(DB::table('share_policies')->value('rules'), true);
- $this->assertArrayNotHasKey('third:3', $rules, 'migration removes third-party lottery from share rules');
- }
- }
|