AgentJointVentureTest.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Agent as AdminAgent;
  4. use App\Models\Agent\Agent;
  5. use App\Models\Agent\AgentEquityOrder;
  6. use App\Models\Agent\AgentProfitCommissionProfile;
  7. use App\Models\Agent\AgentProfitEntry;
  8. use App\Models\Agent\AgentRebateRecord;
  9. use App\Models\Agent\AgentRecharge;
  10. use App\Models\Agent\AgentSetting;
  11. use App\Models\Agent\AgentWithdrawAccount;
  12. use App\Services\Agent\AgentCommissionService;
  13. use App\Services\Agent\AgentEquityService;
  14. use App\Services\Agent\AgentPlanApplicationService;
  15. use App\Services\Agent\AgentProfitCommissionAssignmentService;
  16. use App\Services\Agent\AgentProfitLedgerService;
  17. use App\Services\Agent\AgentRechargeService;
  18. use App\Services\Agent\AgentWalletService;
  19. use App\Services\Agent\AgentWithdrawalService;
  20. use App\Services\MemberDailyReportService;
  21. use App\Services\Sharing\ShareDefaults;
  22. use Carbon\Carbon;
  23. use Illuminate\Http\Request;
  24. use Illuminate\Support\Facades\DB;
  25. use PHPUnit\Framework\TestCase;
  26. use Tests\Support\AgentTestEnvironment;
  27. class AgentJointVentureTest extends TestCase
  28. {
  29. use AgentTestEnvironment;
  30. private AgentWalletService $wallets;
  31. private AgentProfitLedgerService $ledger;
  32. private AgentRechargeService $recharges;
  33. private AgentPlanApplicationService $plans;
  34. private function services(): void
  35. {
  36. $assignments = new AgentProfitCommissionAssignmentService();
  37. $this->wallets = new AgentWalletService();
  38. $this->ledger = new AgentProfitLedgerService($this->wallets, $assignments);
  39. $this->recharges = new AgentRechargeService($this->wallets, new AgentEquityService($this->wallets));
  40. $this->plans = new AgentPlanApplicationService($assignments, $this->wallets);
  41. DB::table('config')->insert([['field' => 'receiving_type', 'val' => '2'], ['field' => 'exchange_rate_rmb', 'val' => '7'],
  42. ['field' => 'exchange_rate_difference', 'val' => '0.5']]);
  43. }
  44. private function profile(string $margin = '7000', string $rate = '10'): AgentProfitCommissionProfile
  45. {
  46. $values = ['name' => 'Plan ' . $margin, 'margin_amount' => $margin];
  47. foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) $values[$field] = $rate;
  48. return AgentProfitCommissionProfile::query()->create($values);
  49. }
  50. /** 合营代理:申请方案并审核通过,再充值 $usdt USDT(汇率 7)。 */
  51. private function jointAgent(AgentProfitCommissionProfile $profile, string $usdt): Agent
  52. {
  53. $agent = $this->agent();
  54. $application = $this->plans->apply($agent, (int) $profile->id);
  55. $this->plans->audit((int) $application->id, 'approved', 1);
  56. $this->recharge($agent, $usdt);
  57. return $agent->fresh();
  58. }
  59. private function recharge(Agent $agent, string $usdt): void
  60. {
  61. $record = $this->recharges->submitProof($agent, ['net' => 'trc20', 'usdt_amount' => $usdt,
  62. 'to_address' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'image' => 'https://example.test/proof.png']);
  63. $this->recharges->audit((int) $record->id, AgentRecharge::STATUS_SUCCESS, 1);
  64. }
  65. private function member(Agent $agent): int
  66. {
  67. static $number = 0;
  68. return (int) DB::table('users')->insertGetId(['member_id' => 'jv' . ++$number, 'username' => 'jv' . $number,
  69. 'agent_id' => $agent->id, 'created_at' => now()]);
  70. }
  71. private function fact(int $userId, string $net, string $category = 'self:lottery', string $settledAt = '2026-09-07 13:00:00'): object
  72. {
  73. $id = DB::table('share_bet_facts')->insertGetId(['source' => 'test', 'source_id' => uniqid('', true), 'user_id' => $userId,
  74. 'category' => $category, 'bet_at' => $settledAt, 'bet_date' => substr($settledAt, 0, 10), 'settled_at' => $settledAt,
  75. 'bet_amount' => 100, 'effective_amount' => 100, 'net_profit' => $net, 'settled' => 1, 'voided' => 0,
  76. 'created_at' => now(), 'updated_at' => now()]);
  77. return DB::table('share_bet_facts')->find($id);
  78. }
  79. private function process(object $fact, Agent $agent): string
  80. {
  81. return $this->ledger->process($fact, (int) $agent->id);
  82. }
  83. public function test_recharge_becomes_margin_and_activates_plan(): void
  84. {
  85. $this->services();
  86. $agent = $this->jointAgent($this->profile('7000'), '999');
  87. $this->assertSame('6993.0000', $agent->balance);
  88. $this->assertSame('6993.0000', $agent->margin_amount);
  89. $this->assertSame(0, $agent->profit_active, 'below the plan margin the plan stays inactive');
  90. $this->recharge($agent, '1');
  91. $agent = $agent->fresh();
  92. $this->assertSame('7000.0000', $agent->margin_amount);
  93. $this->assertSame(1, $agent->profit_active);
  94. $this->assertSame('0.0000', $this->wallets->withdrawable($agent), 'margin is never withdrawable');
  95. $this->assertSame('0', DB::table('recharges')->count() . '', 'agent recharges never land in member recharges');
  96. }
  97. public function test_share_is_two_sided_and_daily_credit_keeps_realtime_invariant(): void
  98. {
  99. $this->services();
  100. $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
  101. $userId = $this->member($agent);
  102. $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent));
  103. $this->assertSame('counted', $this->process($this->fact($userId, '500', 'third:1'), $agent));
  104. $agent = $agent->fresh();
  105. $this->assertSame('6960.0000', $agent->realtime_balance);
  106. $this->assertSame('7000.0000', $agent->balance, 'balance only moves at the daily credit');
  107. Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
  108. $result = (new AgentCommissionService($this->wallets))->settle('2026-09-07');
  109. $this->assertSame(['created' => 2, 'credited' => 2], $result);
  110. $agent = $agent->fresh();
  111. $this->assertSame('6960.0000', $agent->balance);
  112. $this->assertSame('6960.0000', $agent->realtime_balance);
  113. $this->assertSame('-40.0000', bcadd((string) AgentRebateRecord::query()->sum('profit_commission'), '0', 4));
  114. $this->assertSame(0, AgentProfitEntry::query()->whereNull('rebate_record_id')->count());
  115. $this->assertSame(['created' => 0, 'credited' => 0], (new AgentCommissionService($this->wallets))->settle('2026-09-07'));
  116. }
  117. public function test_dropping_below_stop_percent_skips_later_bets_until_margin_is_refilled(): void
  118. {
  119. $this->services();
  120. $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
  121. $userId = $this->member($agent);
  122. // Member wins 64000: agent share -6400, realtime margin 600 < 10% of 7000.
  123. $this->process($this->fact($userId, '64000'), $agent);
  124. $agent = $agent->fresh();
  125. $this->assertSame('600.0000', $agent->realtime_balance);
  126. $this->assertSame(0, $agent->profit_active);
  127. $this->assertSame('skipped', $this->process($this->fact($userId, '-1000'), $agent));
  128. $this->assertSame('600.0000', $agent->fresh()->realtime_balance);
  129. $this->recharge($agent, '900'); // +6300: realtime 6900 < 7000, still stopped
  130. $this->assertSame(0, $agent->fresh()->profit_active);
  131. $this->recharge($agent, '100'); // realtime 7600 >= 7000
  132. $agent = $agent->fresh();
  133. $this->assertSame(1, $agent->profit_active);
  134. $this->assertSame('14000.0000', $agent->margin_amount);
  135. $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent));
  136. }
  137. public function test_loss_is_capped_at_zero_balance(): void
  138. {
  139. $this->services();
  140. $agent = $this->jointAgent($this->profile('0', '50'), '10'); // 70 margin, no margin requirement
  141. $userId = $this->member($agent);
  142. $this->process($this->fact($userId, '1000'), $agent);
  143. $this->assertSame('0.0000', $agent->fresh()->realtime_balance);
  144. $this->assertSame('-70.0000', AgentProfitEntry::query()->value('share_amount'));
  145. }
  146. public function test_changed_payout_is_corrected_in_place_then_by_revision_after_credit(): void
  147. {
  148. $this->services();
  149. $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
  150. $userId = $this->member($agent);
  151. $fact = $this->fact($userId, '-100');
  152. $this->process($fact, $agent);
  153. DB::table('share_bet_facts')->where('id', $fact->id)->update(['net_profit' => -300]);
  154. $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent));
  155. $this->assertSame(1, AgentProfitEntry::query()->count());
  156. $this->assertSame('30.0000', AgentProfitEntry::query()->value('share_amount'));
  157. Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
  158. (new AgentCommissionService($this->wallets))->settle('2026-09-07');
  159. DB::table('share_bet_facts')->where('id', $fact->id)->update(['voided' => 1]);
  160. $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent));
  161. $revision = AgentProfitEntry::query()->where('revision', 1)->firstOrFail();
  162. $this->assertSame('-30.0000', $revision->share_amount);
  163. $this->assertNull($revision->rebate_record_id);
  164. $this->assertSame('7000.0000', $agent->fresh()->realtime_balance);
  165. }
  166. public function test_only_profit_above_margin_is_withdrawable_and_pending_losses_are_reserved(): void
  167. {
  168. $this->services();
  169. $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
  170. $userId = $this->member($agent);
  171. Agent::query()->whereKey($agent->id)->update(['withdraw_password' => \Illuminate\Support\Facades\Hash::make('pw')]);
  172. $this->process($this->fact($userId, '-15000'), $agent); // +1500
  173. Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
  174. (new AgentCommissionService($this->wallets))->settle('2026-09-07');
  175. $agent = $agent->fresh();
  176. $this->assertSame('1500.0000', $this->wallets->withdrawable($agent));
  177. // Today's pending loss (-500) reduces what may leave now.
  178. $this->process($this->fact($userId, '5000', 'self:lottery', '2026-09-08 01:00:00'), $agent);
  179. $agent = $agent->fresh();
  180. $this->assertSame('1000.0000', $this->wallets->withdrawable($agent));
  181. $account = AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
  182. 'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);
  183. $service = new AgentWithdrawalService($this->wallets);
  184. $withdrawal = $service->request($agent, $account->id, '100', 'pw', 'ok'); // 100 USDT * 7.5
  185. $this->assertSame('750.0000', $withdrawal->amount);
  186. $this->assertSame('7.5000', $withdrawal->exchange_rate);
  187. $this->expectExceptionMessage('可提现盈利不足');
  188. $service->request($agent->fresh(), $account->id, '100', 'pw', 'too-much');
  189. }
  190. public function test_equity_order_waits_for_recharge_then_opens_without_review(): void
  191. {
  192. $this->services();
  193. AgentSetting::current()->update(['equity_enabled' => 1, 'equity_price_per_percent' => 70000,
  194. 'equity_min_percent' => 0.1, 'equity_max_percent' => 1]);
  195. $agent = $this->agent(Agent::TYPE_EQUITY);
  196. $equity = new AgentEquityService($this->wallets);
  197. $order = $equity->order($agent, '0.5');
  198. $this->assertSame(AgentEquityOrder::STATUS_PENDING, $order->status);
  199. $this->assertSame('35000.0000', $order->amount);
  200. $this->recharge($agent, '5000');
  201. $agent = $agent->fresh();
  202. $this->assertSame('0.5000', $agent->equity_percent);
  203. $this->assertSame('0.0000', $agent->balance);
  204. $this->assertSame('0.0000', $agent->margin_amount, 'equity recharges are not margin');
  205. $this->assertSame(AgentEquityOrder::STATUS_COMPLETED, $order->fresh()->status);
  206. $this->expectExceptionMessage('累计持股不能超过');
  207. $equity->order($agent, '0.6');
  208. }
  209. public function test_admin_create_ignores_removed_hierarchy_and_plan_fields(): void
  210. {
  211. $request = Request::create('/admin/agent/create', 'POST', ['username' => 'newagent', 'password' => 'secret1',
  212. 'type' => 2, 'parent_id' => 99, 'owner_user_id' => 5, 'flow_commission_profile_id' => 1,
  213. 'profit_commission_profile_id' => 1]);
  214. $request->user = (object) ['id' => 1];
  215. $this->app->instance('request', $request);
  216. $data = (new AdminAgent())->store($request, $this->agents)->getData(true);
  217. $this->assertSame(0, $data['code'], (string) $data['msg']);
  218. $agent = Agent::query()->where('username', 'newagent')->firstOrFail();
  219. $this->assertSame(Agent::TYPE_EQUITY, $agent->type);
  220. $this->assertNull($agent->parent_id);
  221. $this->assertNull($agent->owner_user_id);
  222. $this->assertNull($agent->profit_commission_profile_id);
  223. $this->assertSame('入股', $agent->type_text);
  224. $this->assertArrayNotHasKey('realtime_balance', $agent->toArray());
  225. }
  226. public function test_member_daily_report_aggregates_bets_funds_and_agent_share(): void
  227. {
  228. $this->services();
  229. $agent = $this->jointAgent($this->profile('0', '10'), '10');
  230. $userId = $this->member($agent);
  231. $memberId = DB::table('users')->where('id', $userId)->value('member_id');
  232. $this->process($this->fact($userId, '-100'), $agent);
  233. $this->fact($userId, '40', 'third:2');
  234. DB::table('recharges')->insert(['member_id' => $memberId, 'status' => 1, 'amount' => 300, 'created_at' => '2026-09-07 09:00:00']);
  235. $service = new MemberDailyReportService();
  236. $this->assertSame(1, $service->refresh('2026-09-07'));
  237. $row = DB::table('member_daily_reports')->first();
  238. $this->assertSame('300.0000', bcadd((string) $row->deposit_amount, '0', 4));
  239. $this->assertSame('-60.0000', bcadd((string) $row->win_loss, '0', 4));
  240. $this->assertSame('10.0000', bcadd((string) $row->agent_share, '0', 4));
  241. $this->assertSame(2, (int) $row->bet_count);
  242. $this->assertSame(1, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $agent)['total']);
  243. $this->assertSame(0, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $this->agent())['total']);
  244. }
  245. public function test_sync_reads_new_facts_once_and_ignores_non_joint_members(): void
  246. {
  247. $this->services();
  248. $agent = $this->jointAgent($this->profile('0', '10'), '10');
  249. $this->fact($this->member($agent), '-100');
  250. $this->fact($this->member($this->agent(Agent::TYPE_EQUITY)), '-100');
  251. $this->fact((int) DB::table('users')->insertGetId(['member_id' => 'plain', 'username' => 'plain']), '-100');
  252. $first = $this->ledger->sync();
  253. $this->assertSame(1, $first['counted']);
  254. $this->assertSame(0, $first['failed']);
  255. $this->assertSame(1, $this->ledger->sync()['unchanged'], 'overlap window re-reads the fact without double counting');
  256. $this->assertSame('80.0000', $agent->fresh()->realtime_balance);
  257. }
  258. public function test_commission_categories_drop_third_party_lottery(): void
  259. {
  260. $this->assertCount(8, ShareDefaults::commissionCategories());
  261. $this->assertArrayNotHasKey('third:3', ShareDefaults::commissionCategories());
  262. $this->assertSame(array_keys(ShareDefaults::commissionCategories()), array_keys(array_intersect_key(
  263. ShareDefaults::commissionCategories(), AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS)));
  264. $this->assertCount(8, AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS);
  265. $rules = json_decode(DB::table('share_policies')->value('rules'), true);
  266. $this->assertArrayNotHasKey('third:3', $rules, 'migration removes third-party lottery from share rules');
  267. }
  268. }