WorldCupReferralRewardServiceTest.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <?php
  2. namespace Tests\Unit;
  3. use App\Services\WorldCup\Repositories\WorldCupReferralRepositoryInterface;
  4. use App\Services\WorldCup\WorldCupReferralRewardService;
  5. use App\Game\GlobalUserInfo;
  6. use Tests\TestCase;
  7. class WorldCupReferralRewardServiceTest extends TestCase
  8. {
  9. public function test_first_deposit_creates_reviewing_reward_for_both_users()
  10. {
  11. $repository = new InMemoryWorldCupReferralRepository();
  12. $repository->bindReferral(1001, 1002, 'manual');
  13. $service = new WorldCupReferralRewardService($repository);
  14. $result = $service->handleFirstDeposit(1002, 120.0, 'order-120');
  15. $this->assertTrue($result['success']);
  16. $this->assertSame('created', $result['status']);
  17. $this->assertCount(1, $repository->rewards);
  18. $this->assertSame(1001, $repository->rewards[0]['referrer_id']);
  19. $this->assertSame(1002, $repository->rewards[0]['invitee_id']);
  20. $this->assertSame(12000, $repository->rewards[0]['first_deposit_amt']);
  21. $this->assertSame(6000, $repository->rewards[0]['reward_each']);
  22. $this->assertSame(12000, $repository->rewards[0]['total_liability']);
  23. $this->assertSame('reviewing', $repository->rewards[0]['status']);
  24. }
  25. public function test_reward_caps_at_one_hundred_dollars_each_and_two_hundred_total()
  26. {
  27. $repository = new InMemoryWorldCupReferralRepository();
  28. $repository->bindReferral(1001, 1002, 'manual');
  29. $service = new WorldCupReferralRewardService($repository);
  30. $service->handleFirstDeposit(1002, 300.0, 'order-300');
  31. $this->assertSame(10000, $repository->rewards[0]['reward_each']);
  32. $this->assertSame(20000, $repository->rewards[0]['total_liability']);
  33. }
  34. public function test_duplicate_order_is_idempotent()
  35. {
  36. $repository = new InMemoryWorldCupReferralRepository();
  37. $repository->bindReferral(1001, 1002, 'manual');
  38. $service = new WorldCupReferralRewardService($repository);
  39. $first = $service->handleFirstDeposit(1002, 120.0, 'order-120');
  40. $second = $service->handleFirstDeposit(1002, 120.0, 'order-120');
  41. $this->assertSame('created', $first['status']);
  42. $this->assertSame('exists', $second['status']);
  43. $this->assertCount(1, $repository->rewards);
  44. }
  45. public function test_current_order_not_marked_successful_yet_still_creates_first_deposit_reward()
  46. {
  47. $repository = new InMemoryWorldCupReferralRepository();
  48. $repository->bindReferral(1001, 1002, 'manual');
  49. $repository->successfulOrderSnByUser[1002] = [];
  50. $service = new WorldCupReferralRewardService($repository);
  51. $result = $service->handleFirstDeposit(1002, 120.0, 'order-pending-status');
  52. $this->assertTrue($result['success']);
  53. $this->assertSame('created', $result['status']);
  54. $this->assertCount(1, $repository->rewards);
  55. $this->assertSame('order-pending-status', $repository->rewards[0]['first_deposit_order_sn']);
  56. }
  57. public function test_non_first_deposit_does_not_create_reward()
  58. {
  59. $repository = new InMemoryWorldCupReferralRepository();
  60. $repository->bindReferral(1001, 1002, 'manual');
  61. $repository->successfulOrderSnByUser[1002] = ['order-old'];
  62. $service = new WorldCupReferralRewardService($repository);
  63. $result = $service->handleFirstDeposit(1002, 120.0, 'order-new');
  64. $this->assertTrue($result['success']);
  65. $this->assertSame('not_first_deposit', $result['status']);
  66. $this->assertCount(0, $repository->rewards);
  67. }
  68. public function test_unbound_invitee_does_not_create_reward()
  69. {
  70. $repository = new InMemoryWorldCupReferralRepository();
  71. $service = new WorldCupReferralRewardService($repository);
  72. $result = $service->handleFirstDeposit(1002, 120.0, 'order-120');
  73. $this->assertTrue($result['success']);
  74. $this->assertSame('not_bound', $result['status']);
  75. $this->assertCount(0, $repository->rewards);
  76. }
  77. public function test_bind_invite_code_prevents_self_invite_and_duplicate_binding()
  78. {
  79. $repository = new InMemoryWorldCupReferralRepository();
  80. $repository->ensureUserState(1001);
  81. $inviteCode = $repository->states[1001]['invite_code'];
  82. $service = new WorldCupReferralRewardService($repository);
  83. $self = $service->bindInvite(1001, $inviteCode, 'manual');
  84. $bound = $service->bindInvite(1002, $inviteCode, 'manual');
  85. $duplicate = $service->bindInvite(1002, $inviteCode, 'manual');
  86. $this->assertFalse($self['success']);
  87. $this->assertSame('Cannot invite yourself', $self['message']);
  88. $this->assertTrue($bound['success']);
  89. $this->assertSame(1001, $repository->states[1002]['referred_by_user_id']);
  90. $this->assertTrue($duplicate['success']);
  91. $this->assertSame('exists', $duplicate['status']);
  92. }
  93. public function test_invite_log_returns_stats_and_invited_users()
  94. {
  95. $repository = new InMemoryWorldCupReferralRepository();
  96. $repository->bindReferral(1001, 1002, 'manual');
  97. $repository->bindReferral(1001, 1003, 'manual');
  98. $repository->createReward([
  99. 'referrer_id' => 1001,
  100. 'invitee_id' => 1002,
  101. 'first_deposit_order_sn' => 'order-120',
  102. 'first_deposit_amt' => 12000,
  103. 'reward_each' => 6000,
  104. 'total_liability' => 12000,
  105. 'risk_score' => 0,
  106. 'risk_level' => 'low',
  107. 'signals' => '[]',
  108. 'status' => 'reviewing',
  109. ]);
  110. $service = new WorldCupReferralRewardService($repository);
  111. $result = $service->inviteLog(1001, 'invited', 20);
  112. $this->assertSame(2, $result['stats']['invited_count']);
  113. $this->assertSame(1, $result['stats']['deposited_count']);
  114. $this->assertSame(1, $result['stats']['awaiting_count']);
  115. $this->assertSame(1002, $result['list'][0]['invitee_id']);
  116. $this->assertSame('deposited', $result['list'][0]['status']);
  117. $this->assertSame(12000, $result['list'][0]['first_deposit_amt']);
  118. $this->assertSame(1003, $result['list'][1]['invitee_id']);
  119. $this->assertSame('awaiting', $result['list'][1]['status']);
  120. }
  121. public function test_invite_log_can_return_first_deposits_only()
  122. {
  123. $repository = new InMemoryWorldCupReferralRepository();
  124. $repository->bindReferral(1001, 1002, 'manual');
  125. $repository->bindReferral(1001, 1003, 'manual');
  126. $repository->createReward([
  127. 'referrer_id' => 1001,
  128. 'invitee_id' => 1002,
  129. 'first_deposit_order_sn' => 'order-120',
  130. 'first_deposit_amt' => 12000,
  131. 'reward_each' => 6000,
  132. 'total_liability' => 12000,
  133. 'risk_score' => 0,
  134. 'risk_level' => 'low',
  135. 'signals' => '[]',
  136. 'status' => 'reviewing',
  137. ]);
  138. $service = new WorldCupReferralRewardService($repository);
  139. $result = $service->inviteLog(1001, 'deposits', 20);
  140. $this->assertCount(1, $result['list']);
  141. $this->assertSame(1002, $result['list'][0]['invitee_id']);
  142. }
  143. public function test_reward_log_returns_reviewing_and_paid_amounts()
  144. {
  145. $repository = new InMemoryWorldCupReferralRepository();
  146. $repository->createReward([
  147. 'referrer_id' => 1001,
  148. 'invitee_id' => 1002,
  149. 'first_deposit_order_sn' => 'order-120',
  150. 'first_deposit_amt' => 12000,
  151. 'reward_each' => 6000,
  152. 'total_liability' => 12000,
  153. 'risk_score' => 0,
  154. 'risk_level' => 'low',
  155. 'signals' => '[]',
  156. 'status' => 'reviewing',
  157. ]);
  158. $repository->createReward([
  159. 'referrer_id' => 1001,
  160. 'invitee_id' => 1003,
  161. 'first_deposit_order_sn' => 'order-40',
  162. 'first_deposit_amt' => 4000,
  163. 'reward_each' => 2000,
  164. 'total_liability' => 4000,
  165. 'risk_score' => 0,
  166. 'risk_level' => 'low',
  167. 'signals' => '[]',
  168. 'status' => 'approved',
  169. ]);
  170. $service = new WorldCupReferralRewardService($repository);
  171. $result = $service->rewardLog(1001, 20);
  172. $this->assertSame(6000, $result['stats']['reviewing_amount']);
  173. $this->assertSame(2000, $result['stats']['paid_amount']);
  174. $this->assertSame('reviewing', $result['list'][0]['status']);
  175. $this->assertSame('paid', $result['list'][1]['status']);
  176. $this->assertSame(GlobalUserInfo::faceidToAvatar(2), $result['list'][0]['avatar']);
  177. }
  178. public function test_reward_log_only_returns_rewards_invited_by_current_user()
  179. {
  180. $repository = new InMemoryWorldCupReferralRepository();
  181. $repository->createReward([
  182. 'referrer_id' => 1001,
  183. 'invitee_id' => 1002,
  184. 'first_deposit_order_sn' => 'order-own',
  185. 'first_deposit_amt' => 12000,
  186. 'reward_each' => 6000,
  187. 'total_liability' => 12000,
  188. 'risk_score' => 0,
  189. 'risk_level' => 'low',
  190. 'signals' => '[]',
  191. 'status' => 'reviewing',
  192. ]);
  193. $repository->createReward([
  194. 'referrer_id' => 9999,
  195. 'invitee_id' => 1001,
  196. 'first_deposit_order_sn' => 'order-parent',
  197. 'first_deposit_amt' => 8000,
  198. 'reward_each' => 4000,
  199. 'total_liability' => 8000,
  200. 'risk_score' => 0,
  201. 'risk_level' => 'low',
  202. 'signals' => '[]',
  203. 'status' => 'approved',
  204. ]);
  205. $service = new WorldCupReferralRewardService($repository);
  206. $result = $service->rewardLog(1001, 20);
  207. $this->assertSame(6000, $result['stats']['reviewing_amount']);
  208. $this->assertSame(0, $result['stats']['paid_amount']);
  209. $this->assertCount(1, $result['list']);
  210. $this->assertSame(1002, $result['list'][0]['invitee_id']);
  211. }
  212. }
  213. class InMemoryWorldCupReferralRepository implements WorldCupReferralRepositoryInterface
  214. {
  215. public $states = [];
  216. public $referrals = [];
  217. public $rewards = [];
  218. public $firstOrderSnByUser = [];
  219. public $successfulOrderSnByUser = [];
  220. public function ensureUserState(int $userId): array
  221. {
  222. if (!isset($this->states[$userId])) {
  223. $this->states[$userId] = [
  224. 'user_id' => $userId,
  225. 'invite_code' => 'WC' . $userId,
  226. 'referred_by_user_id' => null,
  227. 'referral_bind_at' => null,
  228. 'referral_bind_type' => null,
  229. 'device_fp' => null,
  230. 'pay_account_hash' => null,
  231. 'signup_ip' => null,
  232. ];
  233. }
  234. return $this->states[$userId];
  235. }
  236. public function findUserState(int $userId): ?array
  237. {
  238. return $this->states[$userId] ?? null;
  239. }
  240. public function findUserByInviteCode(string $inviteCode): ?array
  241. {
  242. foreach ($this->states as $state) {
  243. if ($state['invite_code'] === $inviteCode) {
  244. return $state;
  245. }
  246. }
  247. return null;
  248. }
  249. public function findReferralByInvitee(int $inviteeId): ?array
  250. {
  251. return $this->referrals[$inviteeId] ?? null;
  252. }
  253. public function bindReferral(int $referrerId, int $inviteeId, string $bindType): array
  254. {
  255. $this->ensureUserState($referrerId);
  256. $this->ensureUserState($inviteeId);
  257. $this->referrals[$inviteeId] = [
  258. 'referrer_id' => $referrerId,
  259. 'invitee_id' => $inviteeId,
  260. 'bind_type' => $bindType,
  261. ];
  262. $this->states[$inviteeId]['referred_by_user_id'] = $referrerId;
  263. $this->states[$inviteeId]['referral_bind_type'] = $bindType;
  264. $this->states[$inviteeId]['referral_bind_at'] = '2026-06-05 12:00:00';
  265. return $this->referrals[$inviteeId];
  266. }
  267. public function isFirstSuccessfulOrder(int $userId, string $orderSn): bool
  268. {
  269. if (array_key_exists($userId, $this->successfulOrderSnByUser)) {
  270. $successfulOrders = $this->successfulOrderSnByUser[$userId];
  271. if (!in_array($orderSn, $successfulOrders, true)) {
  272. return count($successfulOrders) === 0;
  273. }
  274. return reset($successfulOrders) === $orderSn;
  275. }
  276. return ($this->firstOrderSnByUser[$userId] ?? $orderSn) === $orderSn;
  277. }
  278. public function findRewardByInvitee(int $inviteeId): ?array
  279. {
  280. foreach ($this->rewards as $reward) {
  281. if ((int)$reward['invitee_id'] === $inviteeId) {
  282. return $reward;
  283. }
  284. }
  285. return null;
  286. }
  287. public function createReward(array $reward): array
  288. {
  289. $reward['reward_id'] = count($this->rewards) + 1;
  290. $this->rewards[] = $reward;
  291. return $reward;
  292. }
  293. public function paidOrdersMissingRewards(int $limit): array
  294. {
  295. return [];
  296. }
  297. public function inviteLogs(int $referrerId, string $type, int $limit): array
  298. {
  299. $rows = [];
  300. foreach ($this->referrals as $referral) {
  301. if ((int)$referral['referrer_id'] !== $referrerId) {
  302. continue;
  303. }
  304. $reward = $this->findRewardByInvitee((int)$referral['invitee_id']);
  305. if ($type === 'deposits' && !$reward) {
  306. continue;
  307. }
  308. $rows[] = [
  309. 'invitee_id' => (int)$referral['invitee_id'],
  310. 'game_id' => (int)$referral['invitee_id'],
  311. 'bind_at' => $referral['bind_at'] ?? '2026-06-05 12:00:00',
  312. 'first_deposit_amt' => $reward['first_deposit_amt'] ?? 0,
  313. 'reward_each' => $reward['reward_each'] ?? 0,
  314. 'reward_status' => $reward['status'] ?? null,
  315. 'status' => $reward ? 'deposited' : 'awaiting',
  316. ];
  317. }
  318. return array_slice($rows, 0, $limit);
  319. }
  320. public function inviteStats(int $referrerId): array
  321. {
  322. $invited = 0;
  323. $deposited = 0;
  324. foreach ($this->referrals as $referral) {
  325. if ((int)$referral['referrer_id'] !== $referrerId) {
  326. continue;
  327. }
  328. $invited++;
  329. if ($this->findRewardByInvitee((int)$referral['invitee_id'])) {
  330. $deposited++;
  331. }
  332. }
  333. return [
  334. 'invited_count' => $invited,
  335. 'deposited_count' => $deposited,
  336. 'awaiting_count' => $invited - $deposited,
  337. ];
  338. }
  339. public function rewardLogs(int $userId, int $limit): array
  340. {
  341. $rows = [];
  342. foreach ($this->rewards as $reward) {
  343. if ((int)$reward['referrer_id'] !== $userId) {
  344. continue;
  345. }
  346. $rows[] = [
  347. 'reward_id' => (int)$reward['reward_id'],
  348. 'referrer_id' => (int)$reward['referrer_id'],
  349. 'invitee_id' => (int)$reward['invitee_id'],
  350. 'friend_user_id' => (int)$reward['invitee_id'],
  351. 'avatar' => GlobalUserInfo::faceidToAvatar((int)$reward['invitee_id'] - 1000),
  352. 'first_deposit_amt' => (int)$reward['first_deposit_amt'],
  353. 'reward_each' => (int)$reward['reward_each'],
  354. 'status' => $reward['status'] === 'approved' ? 'paid' : $reward['status'],
  355. 'submitted_at' => $reward['submitted_at'] ?? '2026-06-05 12:00:00',
  356. ];
  357. }
  358. return array_slice($rows, 0, $limit);
  359. }
  360. public function rewardStats(int $userId): array
  361. {
  362. $reviewing = 0;
  363. $paid = 0;
  364. foreach ($this->rewards as $reward) {
  365. if ((int)$reward['referrer_id'] !== $userId) {
  366. continue;
  367. }
  368. if ($reward['status'] === 'approved') {
  369. $paid += (int)$reward['reward_each'];
  370. } elseif (in_array($reward['status'], ['reviewing', 'on_hold'], true)) {
  371. $reviewing += (int)$reward['reward_each'];
  372. }
  373. }
  374. return [
  375. 'reviewing_amount' => $reviewing,
  376. 'paid_amount' => $paid,
  377. ];
  378. }
  379. }