cabloy 5.1.115 → 5.1.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-contract-loop/SKILL.md +8 -2
  3. package/.claude/skills/cabloy-contract-loop/evals/evals.json +6 -0
  4. package/.claude/skills/cabloy-contract-loop/references/contract-loop-map.md +13 -0
  5. package/.claude/skills/cabloy-contract-loop/references/resource-custom-state-pattern.md +16 -3
  6. package/.claude/skills/cabloy-contract-loop/references/verification-checklist.md +12 -0
  7. package/.github/workflows/vona-test-mysql.yml +6 -10
  8. package/.github/workflows/vona-test-pg.yml +9 -10
  9. package/.github/workflows/vona-test-sqlite3.yml +3 -0
  10. package/CHANGELOG.md +35 -0
  11. package/CLAUDE.md +2 -0
  12. package/cabloy-docs/.vitepress/config.mjs +6 -0
  13. package/cabloy-docs/ai/docs-skills-rules-mapping.md +7 -0
  14. package/cabloy-docs/backend/aop-overview.md +16 -24
  15. package/cabloy-docs/backend/backend-source-reading-roadmap.md +26 -2
  16. package/cabloy-docs/backend/controller-aop-guide.md +38 -14
  17. package/cabloy-docs/backend/logger-guide.md +1 -0
  18. package/cabloy-docs/backend/menu-guide.md +1 -1
  19. package/cabloy-docs/backend/orm-mutation-guide.md +36 -10
  20. package/cabloy-docs/backend/orm-select-guide.md +15 -10
  21. package/cabloy-docs/backend/rate-limit-guide.md +97 -0
  22. package/cabloy-docs/backend/telemetry-guide.md +93 -0
  23. package/cabloy-docs/backend/vona-source-reading-map.md +44 -1
  24. package/cabloy-docs/frontend/model-resource-owner-pattern.md +2 -0
  25. package/cabloy-docs/frontend/ssr-overview.md +2 -0
  26. package/cabloy-docs/fullstack/admin-resource-and-web-self-service.md +329 -0
  27. package/cabloy-docs/fullstack/contract-loop-playbook.md +1 -1
  28. package/cabloy-docs/fullstack/introduction.md +1 -0
  29. package/e2e/specs/a-commerce/commerce.spec.ts +479 -1
  30. package/package.json +1 -1
  31. package/vona/packages-cli/cli/package.json +1 -1
  32. package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/<%=argv.detailDtoBaseName%>.tsx_ +1 -1
  33. package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/<%=argv.detailDtoMutateName%>.tsx_ +2 -2
  34. package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/<%=argv.detailDtoResItemName%>.tsx_ +10 -10
  35. package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/<%=argv.detailDtoViewName%>.tsx_ +2 -2
  36. package/vona/packages-cli/cli-set-api/package.json +1 -1
  37. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +13 -4
  38. package/vona/packages-vona/vona/package.json +1 -1
  39. package/vona/packages-vona/vona-core/package.json +1 -1
  40. package/vona/packages-vona/vona-core/src/lib/core/logger/utils.ts +12 -3
  41. package/vona/packages-vona/vona-mock/package.json +1 -1
  42. package/vona/pnpm-lock.yaml +237 -164
  43. package/vona/src/backend/config/config/config.ts +1 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/sku.test.ts +1 -1
  45. package/vona/src/suite/a-commerce/modules/commerce-member/src/.metadata/index.ts +81 -25
  46. package/vona/src/suite/a-commerce/modules/commerce-member/src/bean/ssrMenu.address.ts +30 -0
  47. package/vona/src/suite/a-commerce/modules/commerce-member/src/controller/address.ts +42 -20
  48. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineCreate.tsx +35 -0
  49. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineItem.tsx +39 -0
  50. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineReq.tsx +11 -0
  51. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineRes.tsx +11 -0
  52. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineUpdate.tsx +10 -0
  53. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineView.tsx +10 -0
  54. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressSelectResItem.tsx +1 -7
  55. package/vona/src/suite/a-commerce/modules/commerce-member/src/service/address.ts +43 -42
  56. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +168 -127
  57. package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +76 -3
  58. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +3 -0
  59. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +37 -0
  60. package/vona/src/suite/a-commerce/modules/commerce-payment/src/dto/paymentAttemptView.tsx +2 -2
  61. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAttempt.tsx +5 -2
  62. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAudit.tsx +54 -0
  63. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/paymentAudit.ts +10 -0
  64. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/mockPaymentAdapter.ts +18 -0
  65. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +22 -7
  66. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +1 -1
  67. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +6 -1
  68. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +480 -8
  69. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/ssrMenu.test.ts +14 -0
  70. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +309 -3
  71. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +2 -0
  72. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +16 -0
  73. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/ssrMenu.order.ts +30 -0
  74. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/order.ts +69 -0
  75. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/payment.ts +23 -0
  76. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutResult.tsx +4 -4
  77. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderDetail.tsx +72 -0
  78. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderMineReq.tsx +21 -0
  79. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderMineRes.tsx +11 -0
  80. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSelectReq.tsx +25 -0
  81. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSelectRes.tsx +11 -0
  82. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSelectResItem.tsx +38 -0
  83. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderShip.tsx +15 -0
  84. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSummary.tsx +26 -0
  85. package/vona/src/suite/a-commerce/modules/{commerce-member/src/dto/addressCreate.tsx → commerce-trade/src/dto/orderView.tsx} +5 -17
  86. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/paymentOutcomeCreate.tsx +16 -0
  87. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/paymentOutcomeResult.tsx +29 -0
  88. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/shipmentView.tsx +22 -0
  89. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +13 -7
  90. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/shipment.tsx +28 -0
  91. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +1 -0
  92. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/shipment.ts +10 -0
  93. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/cart.ts +14 -0
  94. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +436 -34
  95. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +4 -3
  96. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +160 -80
  97. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +773 -2
  98. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutTransaction.test.ts +212 -0
  99. package/vona/src/suite/a-commerce/modules/commerce-trade/test/order.test.ts +37 -0
  100. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +29 -5
  101. package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +714 -0
  102. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +282 -9
  103. package/vona/src/suite/a-commerce/modules/commerce-trade/test/shipment.test.ts +260 -0
  104. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +15 -1
  105. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +20 -2
  106. package/vona/src/suite/a-commerce/modules/commerce-trade/test/testLock.ts +11 -0
  107. package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +21 -0
  108. package/vona/src/suite/a-home/modules/home-user/src/controller/passportTest.ts +21 -0
  109. package/vona/src/suite/a-home/modules/home-user/test/passportTest.test.ts +77 -0
  110. package/vona/src/suite-vendor/a-vona/modules/a-broadcast/package.json +2 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-broadcast/src/service/broadcast.ts +84 -23
  112. package/vona/src/suite-vendor/a-vona/modules/a-broadcast/src/types/broadcast.ts +7 -1
  113. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  114. package/vona/src/suite-vendor/a-vona/modules/a-core/src/lib/core.ts +8 -0
  115. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +4 -2
  116. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/service/executor.ts +68 -53
  117. package/vona/src/suite-vendor/a-vona/modules/a-orm/cli/model/metadata/generate.ts +10 -0
  118. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  119. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_cache.ts +12 -0
  120. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts +3 -0
  121. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_utils.ts +1 -1
  122. package/vona/src/suite-vendor/a-vona/modules/a-queue/package.json +4 -2
  123. package/vona/src/suite-vendor/a-vona/modules/a-queue/src/service/queue.ts +103 -54
  124. package/vona/src/suite-vendor/a-vona/modules/a-queue/src/types/queue.ts +5 -0
  125. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +48 -0
  126. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/.metadata/index.ts +100 -0
  127. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/.metadata/this.ts +2 -0
  128. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/bean/interceptor.rateLimit.ts +72 -0
  129. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/index.ts +2 -0
  130. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/main.ts +28 -0
  131. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/service/rateLimit.ts +75 -0
  132. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/types/index.ts +1 -0
  133. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/types/rateLimit.ts +19 -0
  134. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/tsconfig.build.json +11 -0
  135. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/tsconfig.json +7 -0
  136. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  137. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +1 -2
  138. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/types/redis.ts +1 -0
  139. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/package.json +63 -0
  140. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/.metadata/index.ts +132 -0
  141. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/.metadata/this.ts +2 -0
  142. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/bean.telemetry.ts +71 -0
  143. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/middlewareSystem.trace.ts +73 -0
  144. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/config/config.ts +83 -0
  145. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/index.ts +2 -0
  146. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/lib/ingress.ts +34 -0
  147. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/monkey.ts +22 -0
  148. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/service/telemetry.ts +201 -0
  149. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/types/index.ts +1 -0
  150. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/types/telemetry.ts +22 -0
  151. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/test/telemetry.test.ts +339 -0
  152. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/tsconfig.build.json +11 -0
  153. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/tsconfig.json +7 -0
  154. package/vona/src/suite-vendor/a-vona/package.json +3 -1
  155. package/vona/src/suite-vendor/a-vona/tsconfig.json +3 -0
  156. package/zova/package.original.json +1 -1
  157. package/zova/packages-cli/cli/package.json +2 -2
  158. package/zova/packages-cli/cli-set-front/package.json +1 -1
  159. package/zova/packages-cli/cli-set-front/src/lib/bean/cli.bin.buildRest.ts +3 -2
  160. package/zova/packages-zova/zova/package.json +2 -2
  161. package/zova/pnpm-lock.yaml +87 -127
  162. package/zova/src/front/config/config/config.cabloyCommerce.ts +4 -0
  163. package/zova/src/suite/a-commerce/modules/commerce-member/cli/openapi.config.ts +9 -1
  164. package/zova/src/suite/a-commerce/modules/commerce-member/src/.metadata/index.ts +16 -0
  165. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/commerceMemberAddress.ts +94 -50
  166. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/schemas.ts +162 -52
  167. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/types.ts +2656 -1298
  168. package/zova/src/suite/a-commerce/modules/commerce-member/src/apiSchema/commerceMemberAddress.ts +23 -13
  169. package/zova/src/suite/a-commerce/modules/commerce-member/src/model/addressMine.ts +67 -0
  170. package/zova/src/suite/a-commerce/modules/commerce-member/src/page/address/controller.tsx +21 -11
  171. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +40 -32
  172. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +638 -633
  173. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +6 -0
  174. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/component/tableCellActionShip.ts +31 -0
  175. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +248 -1
  176. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/page/checkout.ts +19 -0
  177. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/page/order.ts +19 -0
  178. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/page/orders.ts +19 -0
  179. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/page/payment.ts +19 -0
  180. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeOrder.ts +114 -0
  181. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradePayment.ts +36 -0
  182. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +75 -8
  183. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +762 -46
  184. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeOrder.ts +35 -0
  185. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradePayment.ts +13 -0
  186. package/zova/src/suite/a-commerce/modules/commerce-trade/src/bean/tableCell.actionShip.tsx +41 -0
  187. package/zova/src/suite/a-commerce/modules/commerce-trade/src/component/tableCellActionShip/controller.tsx +66 -0
  188. package/zova/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +2 -0
  189. package/zova/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +2 -0
  190. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +38 -0
  191. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/orderMine.ts +31 -0
  192. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/payment.ts +25 -0
  193. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/cart/controller.tsx +14 -3
  194. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/checkout/controller.tsx +124 -0
  195. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/order/controller.tsx +105 -0
  196. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/orders/controller.tsx +95 -0
  197. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/payment/controller.tsx +88 -0
  198. package/zova/src/suite/a-commerce/modules/commerce-trade/src/routes.ts +28 -0
  199. package/zova/src/suite/cabloy-basic/modules/basic-details/src/bean/command.delete.tsx +3 -2
  200. package/zova/src/suite/cabloy-basic/modules/basic-details/src/bean/tableCell.actionUpdate.tsx +2 -1
  201. package/zova/src/suite/cabloy-basic/modules/basic-details/src/bean/tableCell.actionView.tsx +1 -1
  202. package/zova/src/suite/cabloy-basic/modules/basic-details/src/config/locale/en-us.ts +1 -0
  203. package/zova/src/suite/cabloy-basic/modules/basic-details/src/config/locale/zh-cn.ts +1 -0
  204. package/zova/src/suite-vendor/a-zova/modules/a-ssr/package.json +1 -1
  205. package/zova/src/suite-vendor/a-zova/modules/a-ssr/src/lib/ssr.ts +4 -0
  206. package/zova/src/suite-vendor/a-zova/package.json +2 -2
  207. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressUpdate.tsx +0 -39
@@ -4,32 +4,107 @@ import { randomUUID } from 'node:crypto';
4
4
  import { describe, it } from 'node:test';
5
5
  import { app } from 'vona-mock';
6
6
 
7
+ import type { EntityCouponGrant } from '../src/entity/couponGrant.tsx';
8
+ import type { EntityCouponTemplate } from '../src/entity/couponTemplate.tsx';
9
+
7
10
  interface IFixture {
8
11
  templateId?: number;
9
12
  grantId?: number;
13
+ userId?: number;
14
+ templateIds?: number[];
15
+ grantIds?: number[];
16
+ userIds?: number[];
17
+ }
18
+
19
+ function createTestId() {
20
+ return randomUUID().slice(0, 12);
10
21
  }
11
22
 
12
23
  async function cleanup(fixture: IFixture) {
13
24
  const promotion = app.scope('commerce-promotion');
14
- if (fixture.grantId !== undefined) {
15
- await promotion.model.couponAudit.delete({ couponGrantId: fixture.grantId });
16
- await promotion.model.couponGrant.delete({ id: fixture.grantId });
25
+ const grantIds = [...new Set([fixture.grantId, ...(fixture.grantIds ?? [])].filter(Boolean))];
26
+ const templateIds = [
27
+ ...new Set([fixture.templateId, ...(fixture.templateIds ?? [])].filter(Boolean)),
28
+ ];
29
+ const userIds = [...new Set([fixture.userId, ...(fixture.userIds ?? [])].filter(Boolean))];
30
+ for (const grantId of grantIds) {
31
+ await promotion.model.couponAudit.delete({ couponGrantId: grantId });
32
+ await promotion.model.couponGrant.delete({ id: grantId });
17
33
  }
18
- if (fixture.templateId !== undefined) {
19
- await promotion.model.couponTemplate.delete({ id: fixture.templateId });
34
+ for (const templateId of templateIds) {
35
+ await promotion.model.couponTemplate.delete({ id: templateId });
20
36
  }
37
+ for (const userId of userIds) {
38
+ await app.scope('home-user').model.roleUser.delete({ userId });
39
+ await app.bean.user.removeById(userId);
40
+ }
41
+ }
42
+
43
+ async function createTemplate(
44
+ fixture: IFixture,
45
+ suffix: string,
46
+ overrides: Partial<EntityCouponTemplate> = {},
47
+ ): Promise<EntityCouponTemplate> {
48
+ const now = Date.now();
49
+ const template = await app.scope('commerce-promotion').model.couponTemplate.insert({
50
+ name: `Coupon ${suffix}`,
51
+ state: 'active',
52
+ currency: 'USD',
53
+ discountCents: 500,
54
+ minSpendCents: 1_000,
55
+ validFrom: new Date(now - 60_000),
56
+ validUntil: new Date(now + 60_000),
57
+ issuedCount: 0,
58
+ redeemedCount: 0,
59
+ ...overrides,
60
+ });
61
+ (fixture.templateIds ??= []).push(template.id as number);
62
+ return template;
63
+ }
64
+
65
+ async function createGrant(
66
+ fixture: IFixture,
67
+ template: EntityCouponTemplate,
68
+ userId: number,
69
+ suffix: string,
70
+ overrides: Partial<EntityCouponGrant> = {},
71
+ ): Promise<EntityCouponGrant> {
72
+ const grant = await app.scope('commerce-promotion').model.couponGrant.insert({
73
+ templateId: template.id,
74
+ userId,
75
+ couponCode: `grant-${suffix}`,
76
+ state: 'available',
77
+ templateNameSnapshot: template.name,
78
+ currencySnapshot: template.currency,
79
+ discountCentsSnapshot: template.discountCents,
80
+ minSpendCentsSnapshot: template.minSpendCents,
81
+ validFromSnapshot: template.validFrom,
82
+ validUntilSnapshot: template.validUntil,
83
+ ...overrides,
84
+ });
85
+ (fixture.grantIds ??= []).push(grant.id as number);
86
+ return grant;
87
+ }
88
+
89
+ async function registerCustomer(fixture: IFixture, name: string) {
90
+ await app.bean.user.register({ name }, true);
91
+ await app.bean.passport.signinMock(name as any);
92
+ const customer = app.bean.passport.currentUser!;
93
+ (fixture.userIds ??= []).push(customer.id as number);
94
+ return customer;
21
95
  }
22
96
 
23
97
  describe('couponReservation.test.ts', { concurrency: false }, () => {
24
98
  it('enforces fixed-discount eligibility and exact-once transitions', async () => {
25
99
  await app.bean.executor.mockCtx(async () => {
26
100
  const fixture: IFixture = {};
27
- const suffix = randomUUID().slice(0, 12);
101
+ const suffix = createTestId();
28
102
  try {
29
103
  const customerName = `coupon-customer-${suffix}`;
30
104
  await app.bean.user.register({ name: customerName }, true);
31
105
  await app.bean.passport.signinMock(customerName as any);
32
106
  const customer = app.bean.passport.currentUser!;
107
+ fixture.userId = customer.id as number;
33
108
  fixture.templateId = (
34
109
  await app.scope('commerce-promotion').model.couponTemplate.insert({
35
110
  name: `Coupon ${suffix}`,
@@ -39,14 +114,29 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
39
114
  minSpendCents: 1000,
40
115
  validFrom: new Date(Date.now() - 1_000),
41
116
  validUntil: new Date(Date.now() + 60_000),
42
- totalIssueLimit: 1,
117
+ totalIssueLimit: 2,
43
118
  totalUsageLimit: 1,
44
- perCustomerIssueLimit: 1,
119
+ perCustomerIssueLimit: 3,
45
120
  issuedCount: 0,
46
121
  redeemedCount: 0,
47
122
  })
48
123
  ).id as number;
49
124
  const coupon = app.scope('commerce-promotion').service.coupon;
125
+ const [, issuanceLengthError] = await catchError(() =>
126
+ coupon.issue({
127
+ templateId: fixture.templateId!,
128
+ userId: customer.id,
129
+ correlationId: 'x'.repeat(81),
130
+ reason: 'test overlong issue correlation',
131
+ }),
132
+ );
133
+ assert.equal(issuanceLengthError?.code, 400);
134
+ assert.equal(
135
+ await app
136
+ .scope('commerce-promotion')
137
+ .model.couponGrant.get({ couponCode: 'x'.repeat(81) }),
138
+ undefined,
139
+ );
50
140
  const grant = await coupon.issue({
51
141
  templateId: fixture.templateId,
52
142
  userId: customer.id,
@@ -56,6 +146,20 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
56
146
  fixture.grantId = grant.id as number;
57
147
  assert.equal(grant.state, 'available');
58
148
  assert.equal(grant.discountCentsSnapshot, 500);
149
+ const exactLimitGrant = await coupon.issue({
150
+ templateId: fixture.templateId,
151
+ userId: customer.id,
152
+ correlationId: 'a'.repeat(80),
153
+ reason: 'test exact issue correlation limit',
154
+ });
155
+ assert.equal(exactLimitGrant.couponCode.length, 80);
156
+ await app.scope('commerce-promotion').model.couponAudit.delete({
157
+ couponGrantId: exactLimitGrant.id,
158
+ });
159
+ await app.scope('commerce-promotion').model.couponGrant.delete({ id: exactLimitGrant.id });
160
+ await app.scope('commerce-promotion').model.couponTemplate.updateById(fixture.templateId, {
161
+ issuedCount: 1,
162
+ });
59
163
 
60
164
  const [_, belowMinimumError] = await catchError(() =>
61
165
  coupon.reserve({
@@ -95,6 +199,25 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
95
199
  reason: 'test reserve',
96
200
  });
97
201
  assert.equal(replay.couponGrant.id, grant.id);
202
+ const [, wrongCustomerError] = await catchError(() =>
203
+ coupon.reserve({
204
+ couponGrantId: grant.id,
205
+ userId: customer.id + 10_000,
206
+ orderId: 100_001,
207
+ eligibleSubtotalCents: 1_200,
208
+ currency: 'USD',
209
+ correlationId: `reserve-wrong-customer-${suffix}`,
210
+ reason: 'test wrong customer',
211
+ }),
212
+ );
213
+ assert.equal(wrongCustomerError?.code, 404);
214
+ const reserveAuditsBeforeRelease = await app
215
+ .scope('commerce-promotion')
216
+ .model.couponAudit.select({ where: { couponGrantId: grant.id } });
217
+ assert.deepEqual(
218
+ reserveAuditsBeforeRelease.map(item => item.operation),
219
+ ['issue', 'reserve'],
220
+ );
98
221
 
99
222
  const released = await coupon.release({
100
223
  couponGrantId: grant.id,
@@ -103,6 +226,12 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
103
226
  reason: 'test release',
104
227
  });
105
228
  assert.equal(released.state, 'available');
229
+ const releasedPersisted = await app
230
+ .scope('commerce-promotion')
231
+ .model.couponGrant.getById(grant.id);
232
+ assert.equal(releasedPersisted?.reservationOrderId, null);
233
+ assert.equal(releasedPersisted?.reservationCorrelationId, null);
234
+ assert.equal(releasedPersisted?.reservedAt, null);
106
235
  const releaseReplay = await coupon.release({
107
236
  couponGrantId: grant.id,
108
237
  orderId: 100_001,
@@ -124,6 +253,35 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
124
253
  reason: 'test reserve again',
125
254
  });
126
255
  assert.equal(reservedAgain.discountCents, 500);
256
+ const anotherGrant = await coupon.issue({
257
+ templateId: fixture.templateId,
258
+ userId: customer.id,
259
+ correlationId: `issue-another-${suffix}`,
260
+ reason: 'test second grant',
261
+ });
262
+ const [, secondCouponError] = await catchError(() =>
263
+ coupon.reserve({
264
+ couponGrantId: anotherGrant.id,
265
+ userId: customer.id,
266
+ orderId: 100_002,
267
+ eligibleSubtotalCents: 1_200,
268
+ currency: 'USD',
269
+ correlationId: `reserve-another-${suffix}`,
270
+ reason: 'test second coupon',
271
+ }),
272
+ );
273
+ assert.equal(secondCouponError?.code, 409);
274
+ assert.equal(
275
+ (await app.scope('commerce-promotion').model.couponGrant.getById(anotherGrant.id))?.state,
276
+ 'available',
277
+ );
278
+ await app.scope('commerce-promotion').model.couponAudit.delete({
279
+ couponGrantId: anotherGrant.id,
280
+ });
281
+ await app.scope('commerce-promotion').model.couponGrant.delete({ id: anotherGrant.id });
282
+ await app.scope('commerce-promotion').model.couponTemplate.updateById(fixture.templateId, {
283
+ issuedCount: 1,
284
+ });
127
285
  const redeemed = await coupon.redeem({
128
286
  couponGrantId: grant.id,
129
287
  orderId: 100_002,
@@ -154,9 +312,323 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
154
312
  ['issue', 'reserve', 'release', 'reserve', 'redeem'],
155
313
  );
156
314
  } finally {
315
+ await app.bean.passport.signout();
157
316
  await cleanup(fixture);
317
+ }
318
+ });
319
+ });
320
+
321
+ it('rejects invalid, expired, and exhausted issuance and reservation without writes', async () => {
322
+ await app.bean.executor.mockCtx(async () => {
323
+ const fixture: IFixture = {};
324
+ const suffix = createTestId();
325
+ try {
326
+ const customer = await registerCustomer(fixture, `coupon-boundary-${suffix}`);
327
+ const coupon = app.scope('commerce-promotion').service.coupon;
328
+ const now = Date.now();
329
+ const disabledTemplate = await createTemplate(fixture, `disabled-${suffix}`, {
330
+ state: 'disabled',
331
+ });
332
+ const expiredTemplate = await createTemplate(fixture, `expired-${suffix}`, {
333
+ validFrom: new Date(now - 120_000),
334
+ validUntil: new Date(now - 60_000),
335
+ });
336
+ const issueExhaustedTemplate = await createTemplate(fixture, `issue-exhausted-${suffix}`, {
337
+ totalIssueLimit: 1,
338
+ issuedCount: 1,
339
+ });
340
+ const perCustomerLimitedTemplate = await createTemplate(fixture, `per-customer-${suffix}`, {
341
+ perCustomerIssueLimit: 1,
342
+ });
343
+ const perCustomerGrant = await coupon.issue({
344
+ templateId: perCustomerLimitedTemplate.id,
345
+ userId: customer.id,
346
+ correlationId: `issue-per-customer-first-${suffix}`,
347
+ reason: 'test per-customer first issuance',
348
+ });
349
+ (fixture.grantIds ??= []).push(perCustomerGrant.id as number);
350
+ const [__, perCustomerError] = await catchError(() =>
351
+ coupon.issue({
352
+ templateId: perCustomerLimitedTemplate.id,
353
+ userId: customer.id,
354
+ correlationId: `issue-per-customer-second-${suffix}`,
355
+ reason: 'test per-customer exhausted issuance',
356
+ }),
357
+ );
358
+ assert.equal(perCustomerError?.code, 409);
359
+ assert.equal(
360
+ await app
361
+ .scope('commerce-promotion')
362
+ .model.couponGrant.get({ couponCode: `issue-per-customer-second-${suffix}` }),
363
+ undefined,
364
+ );
365
+ assert.equal(
366
+ (
367
+ await app
368
+ .scope('commerce-promotion')
369
+ .model.couponTemplate.getById(perCustomerLimitedTemplate.id)
370
+ )?.issuedCount,
371
+ 1,
372
+ );
373
+ for (const [template, label] of [
374
+ [disabledTemplate, 'disabled'],
375
+ [expiredTemplate, 'expired'],
376
+ [issueExhaustedTemplate, 'issue-exhausted'],
377
+ ] as const) {
378
+ const correlationId = `issue-${label}-${suffix}`;
379
+ const [_, error] = await catchError(() =>
380
+ coupon.issue({
381
+ templateId: template.id,
382
+ userId: customer.id,
383
+ correlationId,
384
+ reason: `test ${label} issuance`,
385
+ }),
386
+ );
387
+ assert.equal(error?.code, 409);
388
+ assert.equal(
389
+ await app
390
+ .scope('commerce-promotion')
391
+ .model.couponGrant.get({ couponCode: correlationId }),
392
+ undefined,
393
+ );
394
+ assert.equal(
395
+ (await app.scope('commerce-promotion').model.couponTemplate.getById(template.id))
396
+ ?.issuedCount,
397
+ template.issuedCount,
398
+ );
399
+ }
400
+
401
+ const disabledReservationTemplate = await createTemplate(
402
+ fixture,
403
+ `reserve-disabled-${suffix}`,
404
+ { state: 'disabled' },
405
+ );
406
+ const expiredReservationTemplate = await createTemplate(
407
+ fixture,
408
+ `reserve-expired-${suffix}`,
409
+ );
410
+ const usageExhaustedTemplate = await createTemplate(
411
+ fixture,
412
+ `reserve-exhausted-${suffix}`,
413
+ { totalUsageLimit: 1, redeemedCount: 1 },
414
+ );
415
+ const disabledGrant = await createGrant(
416
+ fixture,
417
+ disabledReservationTemplate,
418
+ customer.id as number,
419
+ `reserve-disabled-${suffix}`,
420
+ );
421
+ const expiredGrant = await createGrant(
422
+ fixture,
423
+ expiredReservationTemplate,
424
+ customer.id as number,
425
+ `reserve-expired-${suffix}`,
426
+ {
427
+ validFromSnapshot: new Date(now - 120_000),
428
+ validUntilSnapshot: new Date(now - 60_000),
429
+ },
430
+ );
431
+ const usageExhaustedGrant = await createGrant(
432
+ fixture,
433
+ usageExhaustedTemplate,
434
+ customer.id as number,
435
+ `reserve-exhausted-${suffix}`,
436
+ );
437
+ for (const [grant, label] of [
438
+ [disabledGrant, 'disabled'],
439
+ [expiredGrant, 'expired'],
440
+ [usageExhaustedGrant, 'usage-exhausted'],
441
+ ] as const) {
442
+ const [_, error] = await catchError(() =>
443
+ coupon.reserve({
444
+ couponGrantId: grant.id,
445
+ userId: customer.id,
446
+ orderId: 200_000 + grant.id,
447
+ eligibleSubtotalCents: 1_000,
448
+ currency: 'USD',
449
+ correlationId: `reserve-${label}-${suffix}`,
450
+ reason: `test ${label} reservation`,
451
+ }),
452
+ );
453
+ assert.equal(error?.code, 409);
454
+ assert.equal(
455
+ (await app.scope('commerce-promotion').model.couponGrant.getById(grant.id))?.state,
456
+ 'available',
457
+ );
458
+ assert.deepEqual(
459
+ await app
460
+ .scope('commerce-promotion')
461
+ .model.couponAudit.select({ where: { couponGrantId: grant.id } }),
462
+ [],
463
+ );
464
+ }
465
+ } finally {
158
466
  await app.bean.passport.signout();
467
+ await cleanup(fixture);
159
468
  }
160
469
  });
161
470
  });
471
+
472
+ it('rejects a direct issue action for a recipient from another instance', async () => {
473
+ const defaultFixture: IFixture = {};
474
+ const foreignFixture: IFixture = {};
475
+ const suffix = createTestId();
476
+ let template!: EntityCouponTemplate;
477
+ let foreignUserId!: number;
478
+ try {
479
+ await app.bean.executor.mockCtx(async () => {
480
+ template = await createTemplate(defaultFixture, `foreign-recipient-${suffix}`);
481
+ });
482
+ await app.bean.executor.mockCtx(
483
+ async () => {
484
+ const customer = await registerCustomer(foreignFixture, `coupon-foreign-${suffix}`);
485
+ foreignUserId = customer.id as number;
486
+ await app.bean.passport.signout();
487
+ },
488
+ { instanceName: 'shareTest' as any },
489
+ );
490
+ await app.bean.executor.mockCtx(async () => {
491
+ await app.bean.passport.signinMock();
492
+ try {
493
+ const [_, error] = await catchError(() =>
494
+ app.bean.executor.performAction('post', '/commerce/promotion/coupon/issue', {
495
+ body: {
496
+ templateId: template.id,
497
+ userId: foreignUserId,
498
+ correlationId: `foreign-recipient-${suffix}`,
499
+ reason: 'test foreign recipient issue action',
500
+ },
501
+ }),
502
+ );
503
+ assert.equal(error?.code, 404);
504
+ assert.equal(
505
+ await app
506
+ .scope('commerce-promotion')
507
+ .model.couponGrant.get({ couponCode: `foreign-recipient-${suffix}` }),
508
+ undefined,
509
+ );
510
+ assert.equal(
511
+ (await app.scope('commerce-promotion').model.couponTemplate.getById(template.id))
512
+ ?.issuedCount,
513
+ 0,
514
+ );
515
+ } finally {
516
+ await app.bean.passport.signout();
517
+ }
518
+ });
519
+ } finally {
520
+ await app.bean.executor.mockCtx(
521
+ async () => {
522
+ await cleanup(foreignFixture);
523
+ },
524
+ { instanceName: 'shareTest' as any },
525
+ );
526
+ await app.bean.executor.mockCtx(async () => {
527
+ await cleanup(defaultFixture);
528
+ });
529
+ }
530
+ });
531
+
532
+ it('allows exactly one PostgreSQL reservation when a coupon has one total usage', async t => {
533
+ if (process.env.DATABASE_DEFAULT_CLIENT !== 'pg') {
534
+ t.skip('requires PostgreSQL row-lock contention');
535
+ return;
536
+ }
537
+ const fixture: IFixture = {};
538
+ const suffix = createTestId();
539
+ let customerId!: number;
540
+ let customerName!: string;
541
+ let grantIds!: number[];
542
+ try {
543
+ await app.bean.executor.mockCtx(async () => {
544
+ customerName = `coupon-concurrent-${suffix}`;
545
+ const customer = await registerCustomer(fixture, customerName);
546
+ customerId = customer.id as number;
547
+ const template = await createTemplate(fixture, `concurrent-${suffix}`, {
548
+ totalIssueLimit: 2,
549
+ totalUsageLimit: 1,
550
+ });
551
+ const coupon = app.scope('commerce-promotion').service.coupon;
552
+ grantIds = [
553
+ (
554
+ await coupon.issue({
555
+ templateId: template.id,
556
+ userId: customerId,
557
+ correlationId: `concurrent-first-${suffix}`,
558
+ reason: 'test concurrent first issue',
559
+ })
560
+ ).id as number,
561
+ (
562
+ await coupon.issue({
563
+ templateId: template.id,
564
+ userId: customerId,
565
+ correlationId: `concurrent-second-${suffix}`,
566
+ reason: 'test concurrent second issue',
567
+ })
568
+ ).id as number,
569
+ ];
570
+ fixture.grantIds = grantIds;
571
+ await app.bean.passport.signout();
572
+ });
573
+
574
+ const reserveInContext = async (grantId: number, label: string) => {
575
+ return await app.bean.executor.mockCtx(async () => {
576
+ await app.bean.passport.signinMock(customerName as any);
577
+ try {
578
+ return await app.scope('commerce-promotion').service.coupon.reserve({
579
+ couponGrantId: grantId,
580
+ userId: customerId,
581
+ orderId: 300_000 + grantId,
582
+ eligibleSubtotalCents: 1_000,
583
+ currency: 'USD',
584
+ correlationId: `concurrent-${label}-${suffix}`,
585
+ reason: `test concurrent ${label} reservation`,
586
+ });
587
+ } finally {
588
+ await app.bean.passport.signout();
589
+ }
590
+ });
591
+ };
592
+ const results = await Promise.allSettled([
593
+ reserveInContext(grantIds[0], 'first'),
594
+ reserveInContext(grantIds[1], 'second'),
595
+ ]);
596
+ assert.equal(
597
+ results.filter(result => result.status === 'fulfilled').length,
598
+ 1,
599
+ JSON.stringify(results),
600
+ );
601
+ assert.equal(
602
+ results.filter(result => result.status === 'rejected').length,
603
+ 1,
604
+ JSON.stringify(results),
605
+ );
606
+ const rejected = results.find(result => result.status === 'rejected');
607
+ assert.equal(
608
+ (rejected as PromiseRejectedResult | undefined)?.reason?.code,
609
+ 409,
610
+ String((rejected as PromiseRejectedResult | undefined)?.reason),
611
+ );
612
+
613
+ await app.bean.executor.mockCtx(async () => {
614
+ const grants = await app.scope('commerce-promotion').model.couponGrant.select({
615
+ where: { templateId: fixture.templateIds![0] },
616
+ });
617
+ const audits = await app.scope('commerce-promotion').model.couponAudit.select({
618
+ where: { templateId: fixture.templateIds![0] },
619
+ orders: [['id', 'asc']],
620
+ });
621
+ assert.equal(grants.filter(grant => grant.state === 'reserved').length, 1);
622
+ assert.equal(grants.filter(grant => grant.state === 'available').length, 1);
623
+ assert.deepEqual(
624
+ audits.map(audit => audit.operation),
625
+ ['issue', 'issue', 'reserve'],
626
+ );
627
+ });
628
+ } finally {
629
+ await app.bean.executor.mockCtx(async () => {
630
+ await cleanup(fixture);
631
+ });
632
+ }
633
+ });
162
634
  });
@@ -6,8 +6,10 @@ const menuNames = [
6
6
  'commerce-catalog:category#category',
7
7
  'commerce-catalog:product#product',
8
8
  'commerce-catalog:sku#sku',
9
+ 'commerce-member:address#address',
9
10
  'commerce-trade:stockBalance#stockBalance',
10
11
  'commerce-trade:stockAudit#stockAudit',
12
+ 'commerce-trade:order#order',
11
13
  ];
12
14
 
13
15
  async function retrieveMenus() {
@@ -53,6 +55,12 @@ describe('ssrMenu.test.ts', () => {
53
55
  assert.equal(menuSku?.group, catalog.name);
54
56
  assert.equal(menuSku?.meta?.params?.resource, 'commerce-catalog:sku');
55
57
 
58
+ const menuAddress = menusSystemAdmin.menus?.find(
59
+ menu => menu.name === 'commerce-member:address#address',
60
+ );
61
+ assert.equal(menuAddress?.group, catalog.name);
62
+ assert.equal(menuAddress?.meta?.params?.resource, 'commerce-member:address');
63
+
56
64
  const inventory = menusSystemAdmin.groups?.find(
57
65
  group => group.name === 'commerce-siteadmin:inventory',
58
66
  );
@@ -69,6 +77,12 @@ describe('ssrMenu.test.ts', () => {
69
77
  );
70
78
  assert.equal(menuStockAudit?.group, inventory.name);
71
79
  assert.equal(menuStockAudit?.meta?.params?.resource, 'commerce-trade:stockAudit');
80
+
81
+ const menuOrder = menusSystemAdmin.menus?.find(
82
+ menu => menu.name === 'commerce-trade:order#order',
83
+ );
84
+ assert.equal(menuOrder?.group, inventory.name);
85
+ assert.equal(menuOrder?.meta?.params?.resource, 'commerce-trade:order');
72
86
  } finally {
73
87
  await app.bean.passport.signout();
74
88
  }