cabloy 5.1.112 → 5.1.114

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 (157) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-backend-scaffold/SKILL.md +2 -0
  3. package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +13 -0
  4. package/.github/workflows/playwright-e2e.yml +34 -2
  5. package/.github/workflows/vona-test-mysql.yml +12 -0
  6. package/.github/workflows/vona-test-pg.yml +12 -0
  7. package/CHANGELOG.md +27 -0
  8. package/CLAUDE.md +6 -0
  9. package/cabloy-docs/backend/internal-aop-guide.md +35 -0
  10. package/cabloy-docs/backend/migration-and-changes.md +13 -9
  11. package/cabloy-docs/backend/unit-testing.md +97 -1
  12. package/e2e/scripts/startE2eVona.ts +51 -13
  13. package/e2e/specs/a-commerce/commerce.spec.ts +1 -0
  14. package/e2e/specs/cabloy-basic/basic.spec.ts +12 -7
  15. package/package.json +1 -1
  16. package/vona/packages-cli/cli/package.json +1 -1
  17. package/vona/packages-cli/cli-set-api/package.json +1 -1
  18. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +6 -9
  19. package/vona/packages-cli/cli-set-api/toolsIsolate/test.ts +74 -32
  20. package/vona/packages-utils/compose/package.json +1 -1
  21. package/vona/packages-utils/compose/src/index.ts +19 -11
  22. package/vona/packages-vona/vona/package.json +1 -1
  23. package/vona/packages-vona/vona-core/package.json +1 -1
  24. package/vona/packages-vona/vona-core/src/lib/bean/beanContainer.ts +15 -3
  25. package/vona/packages-vona/vona-mock/package.json +1 -1
  26. package/vona/pnpm-lock.yaml +82 -67
  27. package/vona/src/backend/config/config/config.test.ts +1 -1
  28. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +8 -0
  29. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.version.ts +1 -0
  30. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
  31. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
  32. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuAttribute.tsx +17 -0
  33. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/sku.tsx +10 -1
  34. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +234 -0
  35. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +6 -1
  36. package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
  37. package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +228 -0
  38. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +15 -0
  39. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +35 -0
  40. package/vona/src/suite/a-commerce/modules/commerce-payment/src/dto/paymentAttemptView.tsx +26 -0
  41. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAttempt.tsx +34 -0
  42. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/paymentAttempt.ts +10 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +41 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +33 -0
  45. package/vona/src/suite/a-commerce/modules/commerce-promotion/package.json +4 -2
  46. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +533 -2
  47. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/locales.ts +18 -0
  48. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +24 -0
  49. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.version.ts +137 -0
  50. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/ssrMenu.couponTemplate.ts +30 -0
  51. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +37 -0
  52. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +37 -0
  53. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/coupon.ts +37 -0
  54. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/couponTemplate.ts +65 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +22 -0
  56. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponMineItem.tsx +32 -0
  57. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +43 -0
  58. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectReq.tsx +28 -0
  59. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectRes.tsx +11 -0
  60. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectResItem.tsx +39 -0
  61. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +19 -0
  62. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateView.tsx +25 -0
  63. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponAudit.tsx +73 -0
  64. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponGrant.tsx +83 -0
  65. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +82 -0
  66. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/index.ts +1 -0
  67. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponAudit.ts +10 -0
  68. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponGrant.ts +10 -0
  69. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponTemplate.ts +10 -0
  70. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +397 -0
  71. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/couponTemplate.ts +52 -0
  72. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +162 -0
  73. package/vona/src/suite/a-commerce/modules/commerce-seed/src/bean/meta.version.ts +11 -13
  74. package/vona/src/suite/a-commerce/modules/commerce-trade/package.json +4 -1
  75. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +277 -0
  76. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/bean.scheduleOrderExpiry.ts +26 -0
  77. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +9 -0
  78. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +79 -0
  79. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +14 -1
  80. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +14 -1
  81. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +19 -0
  82. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +19 -0
  83. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutResult.tsx +32 -0
  84. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderAddressSnapshot.tsx +33 -0
  85. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderCouponSnapshot.tsx +35 -0
  86. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderLineSkuAttributeSnapshot.tsx +15 -0
  87. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/order.tsx +90 -0
  88. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +36 -0
  89. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderLine.tsx +52 -0
  90. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockAudit.tsx +39 -7
  91. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockReservation.tsx +4 -1
  92. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +15 -0
  93. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderAudit.ts +10 -0
  94. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderLine.ts +10 -0
  95. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +556 -0
  96. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +32 -0
  97. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +8 -3
  98. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +158 -0
  99. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +203 -0
  100. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +163 -0
  101. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +274 -66
  102. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +282 -82
  103. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
  104. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +10 -7
  105. package/vona/src/suite-vendor/a-image/package.json +1 -1
  106. package/vona/src/suite-vendor/a-vona/modules/a-aspect/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-aspect/src/types/aopMethod.ts +16 -3
  108. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +4 -2
  109. package/vona/src/suite-vendor/a-vona/modules/a-core/src/bean/bean.core.ts +6 -0
  110. package/vona/src/suite-vendor/a-vona/modules/a-core/src/index.ts +1 -0
  111. package/vona/src/suite-vendor/a-vona/modules/a-core/src/lib/core.ts +6 -0
  112. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/fetch.ts +7 -0
  113. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/index.ts +1 -0
  114. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  115. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/lib/utils.ts +5 -3
  116. package/vona/src/suite-vendor/a-vona/modules/a-retryable/package.json +50 -0
  117. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/index.ts +55 -0
  118. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/this.ts +2 -0
  119. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/bean/aopMethod.retryable.ts +45 -0
  120. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/index.ts +1 -0
  121. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.build.json +11 -0
  122. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.json +7 -0
  123. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  124. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +2 -2
  125. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  126. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/version.ts +12 -12
  127. package/vona/src/suite-vendor/a-vona/modules/a-version/src/types/version.ts +4 -4
  128. package/vona/src/suite-vendor/a-vona/modules/a-vona/package.json +2 -1
  129. package/vona/src/suite-vendor/a-vona/package.json +2 -1
  130. package/vona/src/suite-vendor/a-vona/tsconfig.json +3 -0
  131. package/zova/packages-utils/zova-jsx/package.json +3 -3
  132. package/zova/packages-zova/zova/package.json +3 -3
  133. package/zova/packages-zova/zova-core/package.json +2 -2
  134. package/zova/pnpm-lock.yaml +14 -14
  135. package/zova/src/suite/a-commerce/modules/commerce-catalog/src/page/product/controller.tsx +0 -1
  136. package/zova/src/suite/a-commerce/modules/commerce-promotion/cli/openapi.config.ts +9 -0
  137. package/zova/src/suite/a-commerce/modules/commerce-promotion/package.json +2 -1
  138. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +115 -1
  139. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/commercePromotionCoupon.ts +24 -0
  140. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/baseURL.ts +5 -0
  141. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/index.ts +3 -0
  142. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +478 -0
  143. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +8187 -0
  144. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/apiSchema/commercePromotionCoupon.ts +13 -0
  145. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/model/coupon.ts +19 -0
  146. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
  147. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +30 -0
  148. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +27 -0
  149. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +92 -52
  150. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +1389 -848
  151. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +13 -0
  152. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +14 -0
  153. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/controller.tsx +17 -7
  154. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.header.tsx +1 -1
  155. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/controller.tsx +17 -7
  156. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +3 -3
  157. package/zova/src/suite-vendor/a-zova/package.json +2 -2
@@ -2,11 +2,16 @@ import type { DtoCartAddItem, DtoCartUpdateItem } from 'vona-module-commerce-tra
2
2
 
3
3
  import { catchError } from '@cabloy/utils';
4
4
  import assert from 'node:assert';
5
+ import { randomUUID } from 'node:crypto';
5
6
  import { describe, it } from 'node:test';
6
7
  import { app } from 'vona-mock';
7
8
 
8
9
  const actionPath = '/commerce/trade/cart';
9
10
 
11
+ function createTestId() {
12
+ return randomUUID().slice(0, 12);
13
+ }
14
+
10
15
  function cartItem(skuId: number, quantity = 1): DtoCartAddItem {
11
16
  return { skuId, quantity };
12
17
  }
@@ -58,7 +63,7 @@ async function createSellableSku(suffix: string) {
58
63
  }
59
64
  }
60
65
 
61
- describe('cartOwnership.test.ts', () => {
66
+ describe('cartOwnership.test.ts', { concurrency: false }, () => {
62
67
  it('denies anonymous cart actions', async () => {
63
68
  await app.bean.executor.mockCtx(async () => {
64
69
  for (const [method, path, options] of [
@@ -78,7 +83,7 @@ describe('cartOwnership.test.ts', () => {
78
83
 
79
84
  it('derives cart ownership from the authenticated customer and scopes every item action', async () => {
80
85
  await app.bean.executor.mockCtx(async () => {
81
- const suffix = `${Date.now()}`;
86
+ const suffix = createTestId();
82
87
  const skuId = await createSellableSku(suffix);
83
88
  const customerA = await registerAndSignin(`cart-a-${suffix}`);
84
89
  const customerB = await registerAndSignin(`cart-b-${suffix}`);
@@ -135,7 +140,7 @@ describe('cartOwnership.test.ts', () => {
135
140
  let itemId!: number;
136
141
  let customerDefault!: Awaited<ReturnType<typeof registerAndSignin>>;
137
142
  await app.bean.executor.mockCtx(async () => {
138
- const suffix = `${Date.now()}`;
143
+ const suffix = createTestId();
139
144
  const skuId = await createSellableSku(suffix);
140
145
  customerDefault = await registerAndSignin(`cart-default-${suffix}`);
141
146
  const cart = await performAs(customerDefault.token, 'post', `${actionPath}/items`, {
@@ -0,0 +1,158 @@
1
+ import assert from 'node:assert';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { describe, it } from 'node:test';
4
+ import { app } from 'vona-mock';
5
+
6
+ interface IFixture {
7
+ categoryId?: number;
8
+ productId?: number;
9
+ skuId?: number;
10
+ balanceId?: number;
11
+ cartId?: number;
12
+ cartItemId?: number;
13
+ addressId?: number;
14
+ orderId?: number;
15
+ orderLineId?: number;
16
+ paymentAttemptId?: number;
17
+ orderAuditId?: number;
18
+ reservationId?: number;
19
+ stockAuditIds?: number[];
20
+ }
21
+
22
+ async function cleanup(fixture: IFixture) {
23
+ const trade = app.scope('commerce-trade');
24
+ const payment = app.scope('commerce-payment');
25
+ const member = app.scope('commerce-member');
26
+ const catalog = app.scope('commerce-catalog');
27
+ if (fixture.paymentAttemptId !== undefined)
28
+ await payment.model.paymentAttempt.delete({ id: fixture.paymentAttemptId });
29
+ if (fixture.orderAuditId !== undefined)
30
+ await trade.model.orderAudit.delete({ id: fixture.orderAuditId });
31
+ if (fixture.skuId !== undefined) await trade.model.stockAudit.delete({ skuId: fixture.skuId });
32
+ if (fixture.reservationId !== undefined)
33
+ await trade.model.stockReservation.delete({ id: fixture.reservationId });
34
+ if (fixture.orderLineId !== undefined)
35
+ await trade.model.orderLine.delete({ id: fixture.orderLineId });
36
+ if (fixture.orderId !== undefined) await trade.model.order.delete({ id: fixture.orderId });
37
+ if (fixture.cartItemId !== undefined)
38
+ await trade.model.cartItem.delete({ id: fixture.cartItemId });
39
+ if (fixture.cartId !== undefined) await trade.model.cart.delete({ id: fixture.cartId });
40
+ if (fixture.balanceId !== undefined)
41
+ await trade.model.stockBalance.delete({ id: fixture.balanceId });
42
+ if (fixture.skuId !== undefined) await catalog.model.sku.delete({ id: fixture.skuId });
43
+ if (fixture.productId !== undefined)
44
+ await catalog.model.product.delete({ id: fixture.productId });
45
+ if (fixture.categoryId !== undefined)
46
+ await catalog.model.category.delete({ id: fixture.categoryId });
47
+ if (fixture.addressId !== undefined) await member.model.address.delete({ id: fixture.addressId });
48
+ }
49
+
50
+ describe('checkoutReservation.test.ts', { concurrency: false }, () => {
51
+ it('creates an atomic checkout from the persisted cart and replays its correlation', async () => {
52
+ await app.bean.executor.mockCtx(async () => {
53
+ const fixture: IFixture = {};
54
+ const suffix = randomUUID().slice(0, 12);
55
+ try {
56
+ await app.bean.passport.signinMock();
57
+ const userId = app.bean.passport.currentUser!.id;
58
+ fixture.categoryId = await app.bean.executor.performAction(
59
+ 'post',
60
+ '/commerce/catalog/category',
61
+ {
62
+ body: { name: `checkout-category-${suffix}`, published: true },
63
+ },
64
+ );
65
+ fixture.productId = await app.bean.executor.performAction(
66
+ 'post',
67
+ '/commerce/catalog/product',
68
+ {
69
+ body: {
70
+ categoryId: fixture.categoryId,
71
+ title: `checkout-product-${suffix}`,
72
+ published: true,
73
+ },
74
+ },
75
+ );
76
+ fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
77
+ body: {
78
+ productId: fixture.productId,
79
+ code: `checkout-sku-${suffix}`,
80
+ priceCents: 1299,
81
+ attributes: [],
82
+ lifecycle: 'active',
83
+ },
84
+ });
85
+ const balance = await app.scope('commerce-trade').service.stockBalance.adjustStock({
86
+ skuId: fixture.skuId,
87
+ delta: 1,
88
+ reason: 'checkout fixture',
89
+ correlationId: `checkout-stock-${suffix}`,
90
+ });
91
+ fixture.balanceId = balance.id as number;
92
+ const auditsBefore = await app.scope('commerce-trade').model.stockAudit.select({
93
+ where: { skuId: fixture.skuId },
94
+ });
95
+ fixture.stockAuditIds = auditsBefore.map(item => item.id as number);
96
+ fixture.addressId = await app.bean.executor.performAction(
97
+ 'post',
98
+ '/commerce/member/address',
99
+ {
100
+ body: {
101
+ recipientName: 'Checkout Customer',
102
+ phone: '15555550123',
103
+ countryCode: 'US',
104
+ region: 'California',
105
+ city: 'San Francisco',
106
+ postalCode: '94105',
107
+ addressLine1: '1 Market Street',
108
+ },
109
+ },
110
+ );
111
+ const cart = await app.scope('commerce-trade').model.cart.insert({ userId });
112
+ fixture.cartId = cart.id as number;
113
+ const cartItem = await app.scope('commerce-trade').model.cartItem.insert({
114
+ cartId: cart.id,
115
+ skuId: fixture.skuId,
116
+ quantity: 1,
117
+ });
118
+ fixture.cartItemId = cartItem.id as number;
119
+
120
+ const result = await app.scope('commerce-trade').service.order.checkout({
121
+ addressId: fixture.addressId,
122
+ correlationId: `checkout-${suffix}`,
123
+ });
124
+ fixture.orderId = result.orderId as number;
125
+ fixture.paymentAttemptId = result.paymentAttemptId as number;
126
+ assert.equal(result.state, 'awaiting_payment');
127
+ assert.equal(result.payableTotalCents, 1299);
128
+ assert.equal(
129
+ await app.scope('commerce-trade').model.cartItem.getById(cartItem.id),
130
+ undefined,
131
+ );
132
+ const line = await app
133
+ .scope('commerce-trade')
134
+ .model.orderLine.get({ orderId: result.orderId });
135
+ fixture.orderLineId = line?.id as number;
136
+ const reservation = await app
137
+ .scope('commerce-trade')
138
+ .model.stockReservation.get({ orderLineId: line?.id });
139
+ fixture.reservationId = reservation?.id as number;
140
+ assert.equal(reservation?.state, 'reserved');
141
+ const audit = await app
142
+ .scope('commerce-trade')
143
+ .model.orderAudit.get({ orderId: result.orderId });
144
+ fixture.orderAuditId = audit?.id as number;
145
+ assert.equal(audit?.operation, 'created');
146
+ const replay = await app.scope('commerce-trade').service.order.checkout({
147
+ addressId: fixture.addressId,
148
+ correlationId: `checkout-${suffix}`,
149
+ });
150
+ assert.equal(replay.orderId, result.orderId);
151
+ assert.equal(replay.paymentAttemptId, result.paymentAttemptId);
152
+ } finally {
153
+ await cleanup(fixture);
154
+ await app.bean.passport.signout();
155
+ }
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,203 @@
1
+ import { catchError } from '@cabloy/utils';
2
+ import assert from 'node:assert';
3
+ import { describe, it } from 'node:test';
4
+ import { app } from 'vona-mock';
5
+
6
+ interface IFixture {
7
+ categoryId?: number;
8
+ productId?: number;
9
+ skuId?: number;
10
+ addressId?: number;
11
+ orderId?: number;
12
+ couponTemplateId?: number;
13
+ couponGrantId?: number;
14
+ }
15
+
16
+ async function cleanup(fixture: IFixture) {
17
+ const promotion = app.scope('commerce-promotion');
18
+ const trade = app.scope('commerce-trade');
19
+ const catalog = app.scope('commerce-catalog');
20
+ const member = app.scope('commerce-member');
21
+ if (fixture.orderId !== undefined) {
22
+ const lines = await trade.model.orderLine.select({ where: { orderId: fixture.orderId } });
23
+ for (const line of lines) {
24
+ await trade.model.stockReservation.delete({ orderLineId: line.id });
25
+ }
26
+ await trade.model.orderLine.delete({ orderId: fixture.orderId });
27
+ await trade.model.order.delete({ id: fixture.orderId });
28
+ }
29
+ if (fixture.couponGrantId !== undefined) {
30
+ await promotion.model.couponAudit.delete({ couponGrantId: fixture.couponGrantId });
31
+ await promotion.model.couponGrant.delete({ id: fixture.couponGrantId });
32
+ }
33
+ if (fixture.couponTemplateId !== undefined) {
34
+ await promotion.model.couponTemplate.delete({ id: fixture.couponTemplateId });
35
+ }
36
+ if (fixture.skuId !== undefined) {
37
+ await trade.model.stockAudit.delete({ skuId: fixture.skuId });
38
+ await trade.model.stockReservation.delete({ skuId: fixture.skuId });
39
+ await trade.model.stockBalance.delete({ skuId: fixture.skuId });
40
+ await catalog.model.sku.delete({ id: fixture.skuId });
41
+ }
42
+ if (fixture.productId !== undefined)
43
+ await catalog.model.product.delete({ id: fixture.productId });
44
+ if (fixture.categoryId !== undefined)
45
+ await catalog.model.category.delete({ id: fixture.categoryId });
46
+ if (fixture.addressId !== undefined) await member.model.address.delete({ id: fixture.addressId });
47
+ }
48
+
49
+ describe('orderSnapshot.test.ts', { concurrency: false }, () => {
50
+ it('persists catalog and address facts independently from later source changes', async () => {
51
+ await app.bean.executor.mockCtx(async () => {
52
+ const fixture: IFixture = {};
53
+ const suffix = `${Date.now()}`;
54
+ try {
55
+ await app.bean.passport.signinMock();
56
+ fixture.categoryId = await app.bean.executor.performAction(
57
+ 'post',
58
+ '/commerce/catalog/category',
59
+ {
60
+ body: { name: `snapshot category ${suffix}`, published: true },
61
+ },
62
+ );
63
+ fixture.productId = await app.bean.executor.performAction(
64
+ 'post',
65
+ '/commerce/catalog/product',
66
+ {
67
+ body: {
68
+ categoryId: fixture.categoryId,
69
+ title: `snapshot product ${suffix}`,
70
+ published: true,
71
+ },
72
+ },
73
+ );
74
+ fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
75
+ body: {
76
+ productId: fixture.productId,
77
+ code: `snapshot-sku-${suffix}`,
78
+ priceCents: 1299,
79
+ attributes: [{ name: 'Color', value: 'Black' }],
80
+ lifecycle: 'active',
81
+ },
82
+ });
83
+ await app.scope('commerce-trade').service.stockBalance.adjustStock({
84
+ skuId: fixture.skuId,
85
+ delta: 2,
86
+ reason: 'snapshot fixture',
87
+ correlationId: `snapshot-stock-${suffix}`,
88
+ });
89
+ await app.bean.passport.signout();
90
+ await app.bean.passport.signinMock();
91
+ fixture.couponTemplateId = (
92
+ await app.scope('commerce-promotion').model.couponTemplate.insert({
93
+ name: `snapshot coupon ${suffix}`,
94
+ state: 'active',
95
+ currency: 'USD',
96
+ discountCents: 500,
97
+ minSpendCents: 1_000,
98
+ validFrom: new Date(Date.now() - 1_000),
99
+ validUntil: new Date(Date.now() + 60_000),
100
+ issuedCount: 0,
101
+ redeemedCount: 0,
102
+ })
103
+ ).id as number;
104
+ const customerId = app.bean.passport.currentUser!.id;
105
+ fixture.couponGrantId = (
106
+ await app.scope('commerce-promotion').service.coupon.issue({
107
+ templateId: fixture.couponTemplateId,
108
+ userId: customerId,
109
+ correlationId: `snapshot-coupon-${suffix}`,
110
+ reason: 'snapshot fixture',
111
+ })
112
+ ).id as number;
113
+ fixture.addressId = await app.bean.executor.performAction(
114
+ 'post',
115
+ '/commerce/member/address',
116
+ {
117
+ body: {
118
+ recipientName: 'Snapshot Recipient',
119
+ phone: '15555550123',
120
+ countryCode: 'US',
121
+ region: 'California',
122
+ city: 'San Francisco',
123
+ postalCode: '94105',
124
+ addressLine1: '1 Market Street',
125
+ },
126
+ },
127
+ );
128
+
129
+ const created = await app.scope('commerce-trade').service.order.createSnapshot({
130
+ addressId: fixture.addressId,
131
+ couponGrantId: fixture.couponGrantId,
132
+ correlationId: `snapshot-order-${suffix}`,
133
+ lines: [{ skuId: fixture.skuId, quantity: 2 }],
134
+ });
135
+ fixture.orderId = created.order.id as number;
136
+ const replay = await app.scope('commerce-trade').service.order.createSnapshot({
137
+ addressId: fixture.addressId,
138
+ couponGrantId: fixture.couponGrantId,
139
+ correlationId: `snapshot-order-${suffix}`,
140
+ lines: [{ skuId: fixture.skuId, quantity: 2 }],
141
+ });
142
+ assert.equal(replay.order.id, created.order.id);
143
+ assert.equal(replay.lines[0].id, created.lines[0].id);
144
+ const [_, conflict] = await catchError(() =>
145
+ app.scope('commerce-trade').service.order.createSnapshot({
146
+ addressId: fixture.addressId,
147
+ correlationId: `snapshot-order-${suffix}`,
148
+ lines: [{ skuId: fixture.skuId, quantity: 1 }],
149
+ }),
150
+ );
151
+ assert.equal(conflict?.code, 409);
152
+ assert.equal(created.order.discountCents, 500);
153
+ assert.equal(created.order.payableTotalCents, 2098);
154
+ assert.equal(created.order.couponSnapshot?.couponGrantId, fixture.couponGrantId);
155
+ assert.equal(created.order.couponSnapshot?.couponCode, `snapshot-coupon-${suffix}`);
156
+ assert.equal(created.lines[0].titleSnapshot, `snapshot product ${suffix}`);
157
+ assert.deepEqual(created.lines[0].skuAttributesSnapshot, [
158
+ { name: 'Color', value: 'Black' },
159
+ ]);
160
+
161
+ await app.scope('commerce-catalog').model.product.updateById(fixture.productId, {
162
+ title: 'Changed product title',
163
+ published: false,
164
+ });
165
+ await app.scope('commerce-catalog').model.sku.updateById(fixture.skuId, {
166
+ code: 'changed-sku-code',
167
+ priceCents: 9999,
168
+ attributes: [{ name: 'Color', value: 'Blue' }],
169
+ lifecycle: 'inactive',
170
+ });
171
+ await app.scope('commerce-member').model.address.updateById(fixture.addressId, {
172
+ city: 'Oakland',
173
+ addressLine1: 'Changed address',
174
+ });
175
+
176
+ const snapshot = await app
177
+ .scope('commerce-trade')
178
+ .service.order.viewSnapshot(created.order.id);
179
+ assert.equal(snapshot?.order.addressSnapshot.city, 'San Francisco');
180
+ assert.equal(snapshot?.order.addressSnapshot.addressLine1, '1 Market Street');
181
+ assert.equal(snapshot?.order.discountCents, 500);
182
+ assert.equal(snapshot?.order.payableTotalCents, 2098);
183
+ assert.equal(snapshot?.order.couponSnapshot?.templateName, `snapshot coupon ${suffix}`);
184
+ assert.equal(snapshot?.lines.length, 1);
185
+ assert.equal(snapshot?.lines[0].skuCodeSnapshot, `snapshot-sku-${suffix}`);
186
+ assert.equal(snapshot?.lines[0].titleSnapshot, `snapshot product ${suffix}`);
187
+ assert.deepEqual(snapshot?.lines[0].skuAttributesSnapshot, [
188
+ { name: 'Color', value: 'Black' },
189
+ ]);
190
+ assert.equal(snapshot?.lines[0].unitPriceCents, 1299);
191
+ assert.equal(snapshot?.lines[0].eligibleSubtotalCents, 2598);
192
+ assert.equal(snapshot?.lines[0].lineTotalCents, 2598);
193
+ const reservation = await app.scope('commerce-trade').model.stockReservation.get({
194
+ orderLineId: created.lines[0].id,
195
+ });
196
+ assert.equal(reservation?.state, 'reserved');
197
+ } finally {
198
+ await cleanup(fixture);
199
+ await app.bean.passport.signout();
200
+ }
201
+ });
202
+ });
203
+ });
@@ -0,0 +1,163 @@
1
+ import assert from 'node:assert';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { describe, it } from 'node:test';
4
+ import { app } from 'vona-mock';
5
+
6
+ interface IFixture {
7
+ categoryId?: number;
8
+ productId?: number;
9
+ skuId?: number;
10
+ balanceId?: number;
11
+ cartId?: number;
12
+ addressId?: number;
13
+ orderId?: number;
14
+ orderLineId?: number;
15
+ reservationId?: number;
16
+ paymentAttemptId?: number;
17
+ orderAuditIds?: number[];
18
+ }
19
+
20
+ async function cleanup(fixture: IFixture) {
21
+ const trade = app.scope('commerce-trade');
22
+ const payment = app.scope('commerce-payment');
23
+ const member = app.scope('commerce-member');
24
+ const catalog = app.scope('commerce-catalog');
25
+ if (fixture.paymentAttemptId !== undefined)
26
+ await payment.model.paymentAttempt.delete({ id: fixture.paymentAttemptId });
27
+ if (fixture.orderId !== undefined)
28
+ await trade.model.orderAudit.delete({ orderId: fixture.orderId });
29
+ if (fixture.skuId !== undefined) await trade.model.stockAudit.delete({ skuId: fixture.skuId });
30
+ if (fixture.reservationId !== undefined)
31
+ await trade.model.stockReservation.delete({ id: fixture.reservationId });
32
+ if (fixture.orderLineId !== undefined)
33
+ await trade.model.orderLine.delete({ id: fixture.orderLineId });
34
+ if (fixture.orderId !== undefined) await trade.model.order.delete({ id: fixture.orderId });
35
+ if (fixture.cartId !== undefined) {
36
+ await trade.model.cartItem.delete({ cartId: fixture.cartId });
37
+ await trade.model.cart.delete({ id: fixture.cartId });
38
+ }
39
+ if (fixture.balanceId !== undefined)
40
+ await trade.model.stockBalance.delete({ id: fixture.balanceId });
41
+ if (fixture.skuId !== undefined) await catalog.model.sku.delete({ id: fixture.skuId });
42
+ if (fixture.productId !== undefined)
43
+ await catalog.model.product.delete({ id: fixture.productId });
44
+ if (fixture.categoryId !== undefined)
45
+ await catalog.model.category.delete({ id: fixture.categoryId });
46
+ if (fixture.addressId !== undefined) await member.model.address.delete({ id: fixture.addressId });
47
+ }
48
+
49
+ describe('reservationExpiry.test.ts', { concurrency: false }, () => {
50
+ it('expires an unpaid order and releases its reservation exactly once', async () => {
51
+ await app.bean.executor.mockCtx(async () => {
52
+ const fixture: IFixture = {};
53
+ const suffix = randomUUID().slice(0, 12);
54
+ try {
55
+ await app.bean.passport.signinMock();
56
+ const userId = app.bean.passport.currentUser!.id;
57
+ fixture.categoryId = await app.bean.executor.performAction(
58
+ 'post',
59
+ '/commerce/catalog/category',
60
+ {
61
+ body: { name: `expiry-category-${suffix}`, published: true },
62
+ },
63
+ );
64
+ fixture.productId = await app.bean.executor.performAction(
65
+ 'post',
66
+ '/commerce/catalog/product',
67
+ {
68
+ body: {
69
+ categoryId: fixture.categoryId,
70
+ title: `expiry-product-${suffix}`,
71
+ published: true,
72
+ },
73
+ },
74
+ );
75
+ fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
76
+ body: {
77
+ productId: fixture.productId,
78
+ code: `expiry-sku-${suffix}`,
79
+ priceCents: 899,
80
+ attributes: [],
81
+ lifecycle: 'active',
82
+ },
83
+ });
84
+ const balance = await app.scope('commerce-trade').service.stockBalance.adjustStock({
85
+ skuId: fixture.skuId,
86
+ delta: 1,
87
+ reason: 'expiry fixture',
88
+ correlationId: `expiry-stock-${suffix}`,
89
+ });
90
+ fixture.balanceId = balance.id as number;
91
+ fixture.addressId = await app.bean.executor.performAction(
92
+ 'post',
93
+ '/commerce/member/address',
94
+ {
95
+ body: {
96
+ recipientName: 'Expiry Customer',
97
+ phone: '15555550124',
98
+ countryCode: 'US',
99
+ region: 'California',
100
+ city: 'San Francisco',
101
+ postalCode: '94105',
102
+ addressLine1: '2 Market Street',
103
+ },
104
+ },
105
+ );
106
+ const cart = await app.scope('commerce-trade').model.cart.insert({ userId });
107
+ fixture.cartId = cart.id as number;
108
+ await app.scope('commerce-trade').model.cartItem.insert({
109
+ cartId: cart.id,
110
+ skuId: fixture.skuId,
111
+ quantity: 1,
112
+ });
113
+ const created = await app.scope('commerce-trade').service.order.checkout({
114
+ addressId: fixture.addressId,
115
+ correlationId: `expiry-${suffix}`,
116
+ });
117
+ fixture.orderId = created.orderId as number;
118
+ fixture.paymentAttemptId = created.paymentAttemptId as number;
119
+ const line = await app
120
+ .scope('commerce-trade')
121
+ .model.orderLine.get({ orderId: created.orderId });
122
+ fixture.orderLineId = line?.id as number;
123
+ const reservation = await app
124
+ .scope('commerce-trade')
125
+ .model.stockReservation.get({ orderLineId: line?.id });
126
+ fixture.reservationId = reservation?.id as number;
127
+ await app.scope('commerce-trade').model.order.updateById(created.orderId, {
128
+ reservationExpiresAt: new Date(Date.now() - 1),
129
+ });
130
+
131
+ assert.equal(
132
+ await app.scope('commerce-trade').service.order.expireIfDue(created.orderId),
133
+ true,
134
+ );
135
+ assert.equal(
136
+ await app.scope('commerce-trade').service.order.expireIfDue(created.orderId),
137
+ false,
138
+ );
139
+ const order = await app.scope('commerce-trade').model.order.getById(created.orderId);
140
+ assert.equal(order?.state, 'expired');
141
+ const released = await app
142
+ .scope('commerce-trade')
143
+ .model.stockReservation.getById(reservation!.id);
144
+ assert.equal(released?.state, 'released');
145
+ const attempt = await app
146
+ .scope('commerce-payment')
147
+ .model.paymentAttempt.getById(created.paymentAttemptId);
148
+ assert.equal(attempt?.state, 'cancelled');
149
+ const audits = await app.scope('commerce-trade').model.orderAudit.select({
150
+ where: { orderId: created.orderId },
151
+ orders: [['id', 'asc']],
152
+ });
153
+ assert.deepEqual(
154
+ audits.map(item => item.operation),
155
+ ['created', 'expired'],
156
+ );
157
+ } finally {
158
+ await cleanup(fixture);
159
+ await app.bean.passport.signout();
160
+ }
161
+ });
162
+ });
163
+ });