water_checkout_snapshot_test.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. <?php
  2. /** Real business writes; all fixtures and payments stay inside a rollback transaction. */
  3. require dirname(__DIR__) . '/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. date_default_timezone_set('Asia/Shanghai');
  7. use app\miniapi\auth\Principal;
  8. use app\miniapi\exception\ApiException;
  9. use app\miniapi\model\MiniUser;
  10. use app\miniapi\model\MiniSession;
  11. use app\miniapi\model\MiniGrant;
  12. use app\miniapi\model\MiniCommand;
  13. use app\miniapi\model\MiniAudit;
  14. use app\miniapi\model\MiniCheckout;
  15. use app\miniapi\model\MiniEnterpriseBalance;
  16. use app\miniapi\model\MiniBalanceLedger;
  17. use app\miniapi\service\Access;
  18. use app\miniapi\service\AuthService;
  19. use app\miniapi\service\CheckoutService;
  20. use app\miniapi\service\IdentityManagement;
  21. use plugin\saiadmin\app\model\water\WaterEnterprise;
  22. use plugin\saiadmin\app\model\water\WaterSku;
  23. use plugin\saiadmin\app\model\water\WaterPackage;
  24. use plugin\saiadmin\app\model\water\WaterAccount;
  25. use plugin\saiadmin\app\model\water\WaterOrder;
  26. use plugin\saiadmin\app\model\water\WaterLedger;
  27. use plugin\saiadmin\app\service\WaterBusiness;
  28. use plugin\saiadmin\app\service\finance\ReceiptSources;
  29. $checks = 0;
  30. function check(bool $ok, string $message): void
  31. {
  32. global $checks;
  33. ++$checks;
  34. if (!$ok) {
  35. throw new RuntimeException($message);
  36. }
  37. }
  38. function reject(callable $callback, int $code): void
  39. {
  40. try {
  41. $callback();
  42. } catch (ApiException $error) {
  43. check($error->getCode() === $code, 'expected rejection ' . $code . ', got ' . $error->getCode());
  44. return;
  45. }
  46. throw new RuntimeException('Expected rejection ' . $code);
  47. }
  48. $models = [MiniUser::class, MiniSession::class, MiniGrant::class, MiniCommand::class, MiniAudit::class,
  49. MiniCheckout::class, MiniEnterpriseBalance::class, MiniBalanceLedger::class, WaterEnterprise::class,
  50. WaterSku::class, WaterPackage::class, WaterAccount::class, WaterOrder::class, WaterLedger::class];
  51. $before = [];
  52. foreach ($models as $model) {
  53. $before[$model] = $model::query()->count();
  54. }
  55. $connection = (new MiniUser())->getConnection();
  56. $connection->beginTransaction();
  57. $tag = 'SNAP-' . bin2hex(random_bytes(4));
  58. try {
  59. $business = new WaterBusiness();
  60. $enterprise = $business->save('enterprise', ['name' => $tag, 'contact' => '测试', 'phone' => '13812345678', 'address' => '回滚地址'], 1)['id'];
  61. $sku = $business->save('sku', ['name' => $tag, 'liters' => 18.9], 1)['id'];
  62. $sku2 = $business->save('sku', ['name' => $tag . '-other', 'liters' => 12], 1)['id'];
  63. $session = (new AuthService())->issue(['appid' => $tag, 'openid' => 'snapshot-user']);
  64. $actor = new Principal((string) $session['user_id']);
  65. (new IdentityManagement())->grant(1, ['user_id' => (int) $actor->userId, 'role' => 'enterprise_admin', 'resource_id' => $enterprise]);
  66. MiniEnterpriseBalance::query()->create(['enterprise_id' => $enterprise, 'balance_cents' => 1000000, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s')]);
  67. $service = new CheckoutService(new Access($actor));
  68. $termsPackage = $business->save('package', ['name' => $tag . '-terms', 'sku_id' => $sku, 'buckets' => 30, 'price_cents' => 69950], 1)['id'];
  69. $termsItems = ['items' => [['package_id' => $termsPackage, 'quantity' => 1]]];
  70. $termsQuote = $service->quote($enterprise, $termsItems);
  71. WaterPackage::query()->where('id', $termsPackage)->update(['buckets' => 60, 'sku_id' => $sku2]);
  72. reject(fn () => $service->create($enterprise, $termsItems + ['expected_amount_cents' => 69950,
  73. 'quote_revision' => $termsQuote['quote_revision'], 'agreement_accepted' => true, 'request_key' => $tag . '-stale-terms']), 40904);
  74. check(MiniCheckout::query()->where('enterprise_id', $enterprise)->count() === 0, 'same-price SKU/bucket changes require reconfirmation');
  75. $number = 0;
  76. $make = function () use (&$number, $business, $service, $enterprise, $sku, $tag) {
  77. $number++;
  78. $package = $business->save('package', ['name' => $tag . '-' . $number, 'sku_id' => $sku, 'buckets' => 30, 'price_cents' => 69950], 1)['id'];
  79. $order = $service->create($enterprise, ['items' => [['package_id' => $package, 'quantity' => 2]], 'expected_amount_cents' => 139900,
  80. 'agreement_accepted' => true, 'request_key' => $tag . '-create-' . $number]);
  81. return [$package, $order];
  82. };
  83. $pay = fn ($order, $suffix) => $service->pay($enterprise, $order['id'], ['payment_method' => 'balance', 'request_key' => $tag . '-pay-' . $suffix]);
  84. foreach (['edited', 'disabled', 'deleted'] as $scenario) {
  85. [$package, $order] = $make();
  86. if ($scenario === 'edited') {
  87. WaterPackage::query()->where('id', $package)->update(['name' => 'changed', 'sku_id' => $sku2, 'price_cents' => 79900, 'buckets' => 60]);
  88. }
  89. if ($scenario === 'disabled') {
  90. WaterPackage::query()->where('id', $package)->update(['status' => 2]);
  91. }
  92. if ($scenario === 'deleted') {
  93. $business->deletePackage(['id' => $package], 1);
  94. }
  95. $balanceBefore = (int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents');
  96. $rightsBefore = (int) WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement');
  97. check($pay($order, $scenario)['status'] === 'paid', $scenario . ' pending checkout pays');
  98. $receipts = WaterOrder::query()->where('reference', 'like', $order['code'] . '-%')->get();
  99. check($receipts->count() === 2, 'quantity produces two receipts');
  100. check((int) $receipts->sum('amount_cents') === 139900, 'receipt total equals snapshot debit');
  101. check((int) $receipts->sum('buckets') === 60, 'snapshot buckets issued');
  102. check($receipts->every(fn ($row) => (int) $row->sku_id === $sku && $row->package_name === $order['items'][0]['name']), 'original SKU and name retained');
  103. check($receipts->every(fn ($row) => (int) $row->created_by === 0), 'mini user IDs do not impersonate admin IDs');
  104. check((int) WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement') === $rightsBefore + 60, 'account credits equal receipt buckets');
  105. check((int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents') === $balanceBefore - 139900, 'snapshot amount deducted');
  106. check((int) MiniBalanceLedger::query()->where('checkout_id', $order['id'])->value('amount_cents') === 139900, 'debit ledger reconciles');
  107. $sources = ReceiptSources::label($receipts->toArray());
  108. check(count($sources) === 2 && array_unique(array_values($sources)) === ['企业余额购水'], 'finance resolves true balance provenance');
  109. check(WaterLedger::query()->where('reference', $receipts[0]->reference)->value('note') === '企业余额支付,按订单快照登记套餐权益', 'correct receipt audit note');
  110. check($pay($order, $scenario)['replayed'] === true, 'retry is idempotent');
  111. reject(fn () => $pay($order, $scenario . '-new-key'), 40902);
  112. check(WaterOrder::query()->where('reference', 'like', $order['code'] . '-%')->count() === 2, 'no duplicate receipts');
  113. }
  114. [$package, $order] = $make();
  115. foreach (['sku', 'expired', 'cancelled', 'corrupt', 'insufficient'] as $scenario) {
  116. WaterSku::query()->where('id', $sku)->update(['status' => $scenario === 'sku' ? 2 : 1]);
  117. MiniCheckout::query()->where('id', $order['id'])->update(['status' => $scenario === 'cancelled' ? 'cancelled' : 'pending',
  118. 'expires_at' => date('Y-m-d H:i:s', time() + ($scenario === 'expired' ? -1 : 1800)),
  119. 'amount_cents' => $scenario === 'corrupt' ? 139901 : 139900]);
  120. $balance = $scenario === 'insufficient' ? 1 : 1000000;
  121. MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->update(['balance_cents' => $balance]);
  122. $rights = WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement');
  123. reject(fn () => $pay($order, $scenario), ['sku' => 40902, 'expired' => 40906, 'cancelled' => 40902, 'corrupt' => 40902, 'insufficient' => 40905][$scenario]);
  124. check(WaterOrder::query()->where('reference', 'like', $order['code'] . '-%')->count() === 0, 'failed payment issues no receipts');
  125. check(MiniBalanceLedger::query()->where('checkout_id', $order['id'])->count() === 0, 'failed payment writes no debit');
  126. check((int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents') === $balance, 'failed payment preserves balance');
  127. check(WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement') === $rights, 'failed payment preserves rights');
  128. }
  129. // Force a failure after the first receipt has been issued: all earlier writes must roll back.
  130. MiniCheckout::query()->where('id', $order['id'])->update(['status' => 'pending', 'amount_cents' => 139900]);
  131. MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->update(['balance_cents' => 1000000]);
  132. $collision = WaterOrder::query()->create(['enterprise_id' => $enterprise, 'package_id' => $package, 'sku_id' => $sku2,
  133. 'buckets' => 1, 'amount_cents' => 1, 'package_name' => 'collision', 'reference' => $order['code'] . '-' . $package . '-2', 'created_by' => 1, 'created_at' => date('Y-m-d H:i:s')]);
  134. $rights = WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement');
  135. reject(fn () => $pay($order, 'collision'), 40902);
  136. check(WaterOrder::query()->where('reference', $order['code'] . '-' . $package . '-1')->count() === 0, 'earlier receipt rolled back on late failure');
  137. check(WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement') === $rights, 'earlier rights rolled back');
  138. check((int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents') === 1000000, 'late failure preserves balance');
  139. check(MiniCheckout::query()->find($order['id'])->status === 'pending', 'late failure preserves pending state');
  140. check(ReceiptSources::label([$collision->toArray()]) === [], 'unpaid checkout prefix is not balance provenance');
  141. } finally {
  142. $connection->rollBack();
  143. }
  144. foreach ($before as $model => $count) {
  145. check($model::query()->count() === $count, 'fixtures restored: ' . $model);
  146. }
  147. echo "PASS {$checks} checkout snapshot checks; all fixture writes rolled back.\n";