water_commission_test.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. <?php
  2. declare(strict_types=1);
  3. /** Water commission accrual regression. Every fixture is rolled back. */
  4. require dirname(__DIR__) . '/vendor/autoload.php';
  5. require __DIR__ . '/support/MiniApiContract.php';
  6. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  7. \support\App::loadAllConfig(['route']);
  8. date_default_timezone_set('Asia/Shanghai');
  9. use plugin\saiadmin\app\model\water\WaterAccount;
  10. use plugin\saiadmin\app\model\water\WaterAgent;
  11. use plugin\saiadmin\app\model\water\WaterCommission;
  12. use plugin\saiadmin\app\model\water\WaterEnterprise;
  13. use plugin\saiadmin\app\model\water\WaterOrder;
  14. use plugin\saiadmin\app\service\WaterAgentDashboard;
  15. use plugin\saiadmin\app\service\WaterAgentManagement;
  16. use plugin\saiadmin\app\service\WaterBusiness;
  17. use plugin\saiadmin\app\service\WaterFinanceDashboard;
  18. use plugin\saiadmin\app\service\agent\WaterCommissionPolicy;
  19. use plugin\saiadmin\exception\ApiException;
  20. use app\miniapi\auth\Principal;
  21. use app\miniapi\model\MiniGrant;
  22. use app\miniapi\model\MiniUser;
  23. use app\miniapi\service\Access;
  24. use app\miniapi\service\AgentQuery;
  25. $checks = 0;
  26. function check(bool $ok, string $label): void
  27. {
  28. global $checks;
  29. $checks++;
  30. if (!$ok) {
  31. throw new RuntimeException($label);
  32. }
  33. }
  34. $business = new WaterBusiness();
  35. $agents = new WaterAgentManagement();
  36. $tag = 'COM-' . bin2hex(random_bytes(4));
  37. $db = (new WaterCommission())->getConnection();
  38. $db->beginTransaction();
  39. try {
  40. $originalPolicy = WaterCommissionPolicy::current();
  41. $defaultPolicy = WaterCommissionPolicy::save([
  42. 'level1_basis_points' => 1000,
  43. 'level2_basis_points' => 500,
  44. 'level3_basis_points' => 200,
  45. 'revision' => $originalPolicy['revision'],
  46. ], 1);
  47. $ids = [];
  48. foreach (['root', 'child', 'leaf', 'unset', 'other'] as $index => $name) {
  49. $parent = match ($name) {
  50. 'root', 'other' => 0,
  51. 'child' => $ids['root'],
  52. 'leaf' => $ids['child'],
  53. default => null,
  54. };
  55. $ids[$name] = $agents->save([
  56. 'name' => $tag . '-' . $name,
  57. 'contact' => 'Test',
  58. 'phone' => 'COM' . $index . substr($tag, 4),
  59. 'region' => 'Test region',
  60. 'parent_id' => $parent,
  61. 'status' => 1,
  62. ], 1)['id'];
  63. }
  64. $sku = $business->save('sku', ['name' => $tag, 'liters' => 18.9], 1)['id'];
  65. $package = $business->save('package', [
  66. 'name' => $tag . '-package',
  67. 'sku_id' => $sku,
  68. 'buckets' => 5,
  69. 'price_cents' => 12345,
  70. ], 1)['id'];
  71. $enterpriseIds = [];
  72. foreach (['root', 'child', 'leaf', 'unset'] as $name) {
  73. $enterpriseIds[$name] = $business->save('enterprise', [
  74. 'name' => $tag . '-' . $name . '-company',
  75. 'contact' => 'Test',
  76. 'phone' => 'COM' . $name,
  77. 'address' => 'Test address',
  78. 'agent_id' => $ids[$name],
  79. ], 1)['id'];
  80. }
  81. foreach (['root' => [1, 1000, 1235], 'child' => [2, 500, 617], 'leaf' => [3, 200, 247]] as $name => [$level, $rate, $amount]) {
  82. $reference = $tag . '-' . $name;
  83. $order = $business->purchase(['enterprise_id' => $enterpriseIds[$name], 'package_id' => $package, 'reference' => $reference], 1);
  84. $commission = WaterCommission::query()->where('order_id', $order['id'])->sole();
  85. check((int) $commission->agent_id === (int) $ids[$name], $name . ' direct agent only');
  86. check((int) $commission->agent_level === $level && (int) $commission->rate_basis_points === $rate, $name . ' rate snapshot');
  87. check((int) $commission->base_amount_cents === 12345 && (int) $commission->amount_cents === $amount, $name . ' rounded amount');
  88. check($commission->status === 'pending' && $commission->settled_at === null, $name . ' not withdrawable');
  89. check(WaterCommission::query()->where('order_id', $order['id'])->count() === 1, $name . ' has no ancestor commission');
  90. $replay = $business->purchase(['enterprise_id' => $enterpriseIds[$name], 'package_id' => $package, 'reference' => $reference], 1);
  91. check(($replay['replayed'] ?? false) === true && WaterCommission::query()->where('order_id', $order['id'])->count() === 1, $name . ' idempotent');
  92. }
  93. WaterEnterprise::query()->whereKey($enterpriseIds['leaf'])->update(['agent_id' => $ids['other']]);
  94. $original = WaterCommission::query()->where('enterprise_id', $enterpriseIds['leaf'])->first();
  95. check((int) $original->agent_id === (int) $ids['leaf'] && (int) $original->agent_level === 3, 'old commission keeps attribution');
  96. $newOrder = $business->purchase(['enterprise_id' => $enterpriseIds['leaf'], 'package_id' => $package, 'reference' => $tag . '-transfer'], 1);
  97. $newCommission = WaterCommission::query()->where('order_id', $newOrder['id'])->sole();
  98. check((int) $newCommission->agent_id === (int) $ids['other'] && (int) $newCommission->agent_level === 1, 'new order uses new owner');
  99. $noLevel = $business->purchase(['enterprise_id' => $enterpriseIds['unset'], 'package_id' => $package, 'reference' => $tag . '-unset'], 1);
  100. check(WaterCommission::query()->where('order_id', $noLevel['id'])->count() === 0, 'unknown level does not accrue');
  101. $demo = $business->purchase(['enterprise_id' => $enterpriseIds['root'], 'package_id' => $package, 'reference' => 'TEST-' . $tag], 1);
  102. check(WaterCommission::query()->where('order_id', $demo['id'])->count() === 0, 'demo receipt does not accrue');
  103. WaterAgent::query()->whereKey($ids['root'])->update(['status' => 2]);
  104. $disabled = $business->purchase(['enterprise_id' => $enterpriseIds['root'], 'package_id' => $package, 'reference' => $tag . '-disabled'], 1);
  105. check(WaterCommission::query()->where('order_id', $disabled['id'])->count() === 0, 'disabled direct agent does not accrue');
  106. WaterAgent::query()->whereKey($ids['root'])->update(['status' => 1]);
  107. $accountBefore = (int) WaterAccount::query()->where('enterprise_id', $enterpriseIds['root'])->where('sku_id', $sku)->value('entitlement');
  108. WaterAgent::query()->whereKey($ids['root'])->update(['parent_id' => 999999999]);
  109. try {
  110. $business->purchase(['enterprise_id' => $enterpriseIds['root'], 'package_id' => $package, 'reference' => $tag . '-broken'], 1);
  111. throw new RuntimeException('broken hierarchy should fail');
  112. } catch (DomainException $exception) {
  113. check($exception instanceof DomainException, 'broken hierarchy rejected');
  114. }
  115. check(WaterOrder::query()->where('reference', $tag . '-broken')->count() === 0, 'failed accrual rolls back order');
  116. check((int) WaterAccount::query()->where('enterprise_id', $enterpriseIds['root'])->where('sku_id', $sku)->value('entitlement') === $accountBefore, 'failed accrual rolls back entitlement');
  117. WaterAgent::query()->whereKey($ids['root'])->update(['parent_id' => 0]);
  118. check(WaterCommissionPolicy::amount(1, 1) === 0 && WaterCommissionPolicy::amount(5, 1) === 1, 'half cent rounds up');
  119. check(WaterCommissionPolicy::amount(12345, 3) === 247, 'third level integer rounding');
  120. $finance = (new WaterFinanceDashboard())->dashboard([]);
  121. check($finance['capabilities']['commissions'] === true && $finance['capabilities']['withdrawals'] === false, 'finance shows accrual without withdrawal');
  122. check($finance['summary']['pending_commission_cents'] >= 3334, 'finance includes pending accrual');
  123. $dashboard = (new WaterAgentDashboard())->detail($ids['leaf']);
  124. check($dashboard['agent']['commission_cents'] === 247, 'admin agent shows own commission');
  125. $now = date('Y-m-d H:i:s');
  126. $userId = MiniUser::query()->insertGetId([
  127. 'appid' => 'commission-fixture-' . $tag,
  128. 'openid' => 'commission-fixture-' . $tag,
  129. 'nickname' => 'Commission test',
  130. 'status' => 1,
  131. 'created_at' => $now,
  132. 'updated_at' => $now,
  133. ]);
  134. MiniGrant::query()->create([
  135. 'user_id' => $userId,
  136. 'role' => 'agent',
  137. 'resource_id' => $ids['leaf'],
  138. 'status' => 1,
  139. 'created_by' => 1,
  140. 'created_at' => $now,
  141. 'updated_at' => $now,
  142. ]);
  143. $query = new AgentQuery(new Access(new Principal((string) $userId)));
  144. $miniCommissions = $query->commissions($ids['leaf']);
  145. check($miniCommissions['pending_cents'] === 247 && $miniCommissions['withdrawable_cents'] === 0, 'mini API shows pending but not withdrawable');
  146. check($miniCommissions['total'] === 1 && $miniCommissions['items'][0]['status'] === 'pending', 'mini API lists own direct commission');
  147. check((new MiniApiContract())->data('Commissions', $miniCommissions), 'mini API matches OpenAPI contract');
  148. check($query->commissions($ids['leaf'], ['source' => 'demo'])['items'] === [], 'missing demo fixture never falls through to real commission');
  149. $updatedPolicy = WaterCommissionPolicy::save([
  150. 'level1_basis_points' => 800,
  151. 'level2_basis_points' => 400,
  152. 'level3_basis_points' => 100,
  153. 'revision' => $defaultPolicy['revision'],
  154. ], 1);
  155. check($updatedPolicy['revision'] === $defaultPolicy['revision'] + 1 && WaterCommissionPolicy::rate(1) === 800, 'edited rates are persisted');
  156. check(str_contains(WaterCommissionPolicy::message(), '8%') && str_contains(WaterCommissionPolicy::message(), '4%'), 'commission explanation follows saved rates');
  157. $afterChange = $business->purchase(['enterprise_id' => $enterpriseIds['child'], 'package_id' => $package, 'reference' => $tag . '-new-rate'], 1);
  158. $changedCommission = WaterCommission::query()->where('order_id', $afterChange['id'])->sole();
  159. check((int) $changedCommission->rate_basis_points === 400 && (int) $changedCommission->amount_cents === 494, 'new order uses edited second-level rate');
  160. check((int) WaterCommission::query()->where('enterprise_id', $enterpriseIds['child'])->orderBy('id')->first()->rate_basis_points === 500, 'earlier order retains original rate');
  161. $validInput = [
  162. 'level1_basis_points' => 800,
  163. 'level2_basis_points' => 400,
  164. 'level3_basis_points' => 100,
  165. 'revision' => $updatedPolicy['revision'],
  166. ];
  167. foreach ([
  168. ['level1_basis_points' => 10001, 'revision' => $updatedPolicy['revision']],
  169. ['level1_basis_points' => 1000, 'revision' => $defaultPolicy['revision']],
  170. ['level1_basis_points' => 1000, 'revision' => $updatedPolicy['revision'], 'unexpected' => true],
  171. ] as $invalid) {
  172. try {
  173. WaterCommissionPolicy::save(array_replace($validInput, $invalid), 1);
  174. throw new RuntimeException('invalid policy should fail');
  175. } catch (ApiException $exception) {
  176. check(true, 'invalid or stale policy rejected');
  177. }
  178. }
  179. } finally {
  180. $db->rollBack();
  181. }
  182. check(!WaterCommission::query()->where('enterprise_name', 'like', $tag . '%')->exists(), 'commission fixtures rolled back');
  183. if (in_array('--http', $argv, true)) {
  184. $token = \Tinywan\Jwt\JwtToken::generateToken([
  185. 'id' => 1, 'username' => 'admin', 'plat' => 'saiadmin', 'type' => 'web',
  186. 'client' => $tag, 'access_exp' => 120, 'refresh_exp' => 120,
  187. ])['access_token'];
  188. $request = curl_init('http://127.0.0.1:8787/core/water/agents/commission-policy');
  189. curl_setopt_array($request, [
  190. CURLOPT_RETURNTRANSFER => true,
  191. CURLOPT_TIMEOUT => 15,
  192. CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
  193. ]);
  194. $response = curl_exec($request);
  195. $status = curl_getinfo($request, CURLINFO_HTTP_CODE);
  196. curl_close($request);
  197. $body = json_decode((string) $response, true);
  198. check($status === 200 && ($body['code'] ?? 0) === 200, 'commission policy HTTP route');
  199. check(($body['data']['level1_basis_points'] ?? null) === WaterCommissionPolicy::rate(1), 'HTTP rates match database');
  200. }
  201. echo "PASS: {$checks} commission checks; all fixtures rolled back.\n";