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
@@ -0,0 +1,556 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+
3
+ import { BeanBase } from 'vona';
4
+ import { Service } from 'vona-module-a-bean';
5
+ import { Core } from 'vona-module-a-core';
6
+
7
+ import type { DtoCheckoutCreate } from '../dto/checkoutCreate.tsx';
8
+ import type { DtoCheckoutResult } from '../dto/checkoutResult.tsx';
9
+ import type { DtoOrderAddressSnapshot } from '../dto/orderAddressSnapshot.tsx';
10
+ import type { DtoOrderCouponSnapshot } from '../dto/orderCouponSnapshot.tsx';
11
+ import type { DtoOrderLineSkuAttributeSnapshot } from '../dto/orderLineSkuAttributeSnapshot.tsx';
12
+ import type { EntityOrder } from '../entity/order.tsx';
13
+ import type { EntityOrderLine } from '../entity/orderLine.tsx';
14
+
15
+ const maxOrderCents = 2_147_483_647;
16
+
17
+ const serializationRetryOptions = {
18
+ retries: 1,
19
+ factor: 1,
20
+ minTimeout: 0,
21
+ maxTimeout: 0,
22
+ randomize: false,
23
+ errorCodes: ['40001'],
24
+ };
25
+
26
+ export interface IOrderSnapshotLineCommand {
27
+ skuId: TableIdentity;
28
+ quantity: number;
29
+ }
30
+
31
+ export interface IOrderSnapshotCreateCommand {
32
+ addressId: TableIdentity;
33
+ correlationId: string;
34
+ couponGrantId?: TableIdentity;
35
+ lines: IOrderSnapshotLineCommand[];
36
+ }
37
+
38
+ export interface IOrderSnapshotCreateResult {
39
+ order: EntityOrder;
40
+ lines: EntityOrderLine[];
41
+ }
42
+
43
+ interface IPreparedOrderLine {
44
+ index: number;
45
+ skuId: TableIdentity;
46
+ productId: TableIdentity;
47
+ skuCodeSnapshot: string;
48
+ titleSnapshot: string;
49
+ skuAttributesSnapshot: DtoOrderLineSkuAttributeSnapshot[];
50
+ unitPriceCents: number;
51
+ quantity: number;
52
+ eligibleSubtotalCents: number;
53
+ }
54
+
55
+ @Service()
56
+ export class ServiceOrder extends BeanBase {
57
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
58
+ @Core.retryable(serializationRetryOptions)
59
+ async createSnapshot(command: IOrderSnapshotCreateCommand): Promise<IOrderSnapshotCreateResult> {
60
+ this._assertCommand(command);
61
+
62
+ const existingOrder = await this.scope.model.order.getForUpdate({
63
+ userId: this.bean.passport.currentUser!.id,
64
+ correlationId: command.correlationId,
65
+ });
66
+ if (existingOrder) {
67
+ await this._assertExistingSnapshotMatchesCommand(existingOrder, command);
68
+ return (await this._viewSnapshot(existingOrder))!;
69
+ }
70
+
71
+ const address = await this.$scope.commerceMember.model.address.get({
72
+ id: command.addressId,
73
+ userId: this.bean.passport.currentUser!.id,
74
+ });
75
+ if (!address) {
76
+ this.app.throw(404, 'Address not found');
77
+ }
78
+
79
+ const preparedLines = await Promise.all(
80
+ command.lines.map((line, index) => this._prepareLine(line, index)),
81
+ );
82
+ const eligibleSubtotalCents = preparedLines.reduce((total, line) => {
83
+ const nextTotal = total + line.eligibleSubtotalCents;
84
+ if (!Number.isSafeInteger(nextTotal) || nextTotal > maxOrderCents) {
85
+ this.app.throw(400, 'order total exceeds the supported range');
86
+ }
87
+ return nextTotal;
88
+ }, 0);
89
+ const order = await this.scope.model.order.insert({
90
+ userId: this.bean.passport.currentUser!.id,
91
+ addressId: address.id,
92
+ correlationId: command.correlationId,
93
+ addressSnapshot: this._toAddressSnapshot(address),
94
+ state: 'awaiting_payment',
95
+ currency: 'USD',
96
+ eligibleSubtotalCents,
97
+ discountCents: 0,
98
+ payableTotalCents: eligibleSubtotalCents,
99
+ reservationExpiresAt: new Date(Date.now() + 30 * 60 * 1000),
100
+ });
101
+ let couponSnapshot: DtoOrderCouponSnapshot | undefined;
102
+ if (command.couponGrantId) {
103
+ const reservation = await this.$scope.commercePromotion.service.coupon.reserve({
104
+ couponGrantId: command.couponGrantId,
105
+ userId: this.bean.passport.currentUser!.id,
106
+ orderId: order.id,
107
+ eligibleSubtotalCents,
108
+ currency: 'USD',
109
+ correlationId: `${command.correlationId}:coupon`,
110
+ reason: 'order snapshot created',
111
+ });
112
+ const couponGrant = reservation.couponGrant;
113
+ couponSnapshot = {
114
+ couponGrantId: couponGrant.id,
115
+ couponTemplateId: couponGrant.templateId,
116
+ couponCode: couponGrant.couponCode,
117
+ templateName: couponGrant.templateNameSnapshot,
118
+ currency: couponGrant.currencySnapshot,
119
+ fixedDiscountCents: couponGrant.discountCentsSnapshot,
120
+ minSpendCents: couponGrant.minSpendCentsSnapshot,
121
+ appliedDiscountCents: reservation.discountCents,
122
+ };
123
+ await this.scope.model.order.updateById(order.id, {
124
+ couponSnapshot,
125
+ discountCents: reservation.discountCents,
126
+ payableTotalCents: eligibleSubtotalCents - reservation.discountCents,
127
+ });
128
+ order.couponSnapshot = couponSnapshot;
129
+ order.discountCents = reservation.discountCents;
130
+ order.payableTotalCents = eligibleSubtotalCents - reservation.discountCents;
131
+ }
132
+ const lines = await Promise.all(
133
+ preparedLines.map(async line => {
134
+ const orderLine = await this.scope.model.orderLine.insert({
135
+ orderId: order.id,
136
+ skuId: line.skuId,
137
+ productId: line.productId,
138
+ skuCodeSnapshot: line.skuCodeSnapshot,
139
+ titleSnapshot: line.titleSnapshot,
140
+ skuAttributesSnapshot: line.skuAttributesSnapshot,
141
+ unitPriceCents: line.unitPriceCents,
142
+ quantity: line.quantity,
143
+ eligibleSubtotalCents: line.eligibleSubtotalCents,
144
+ lineTotalCents: line.eligibleSubtotalCents,
145
+ });
146
+ await this.scope.service.stockBalance.reserve({
147
+ skuId: orderLine.skuId,
148
+ orderLineId: orderLine.id,
149
+ quantity: orderLine.quantity,
150
+ correlationId: `${command.correlationId}:line:${line.index}`,
151
+ reason: 'order snapshot created',
152
+ });
153
+ return orderLine;
154
+ }),
155
+ );
156
+ return { order, lines };
157
+ }
158
+
159
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
160
+ @Core.retryable(serializationRetryOptions)
161
+ async checkout(command: DtoCheckoutCreate): Promise<DtoCheckoutResult> {
162
+ this._assertCheckoutCommand(command);
163
+ const userId = this.bean.passport.currentUser!.id;
164
+ const existingOrder = await this.scope.model.order.getForUpdate({
165
+ userId,
166
+ correlationId: command.correlationId,
167
+ });
168
+ if (existingOrder) {
169
+ if (
170
+ String(existingOrder.addressId) !== String(command.addressId) ||
171
+ String(existingOrder.couponSnapshot?.couponGrantId ?? '') !==
172
+ String(command.couponGrantId ?? '')
173
+ ) {
174
+ this.app.throw(409, 'checkout correlation conflicts with an existing order');
175
+ }
176
+ return await this._checkoutResult(existingOrder);
177
+ }
178
+
179
+ const cart = await this.scope.model.cart.getForUpdate({ userId });
180
+ if (!cart) this.app.throw(409, 'cart is empty');
181
+ const cartItems = await this.scope.model.cartItem.select({
182
+ where: { cartId: cart.id },
183
+ orders: [['skuId', 'asc']],
184
+ });
185
+ if (cartItems.length === 0) this.app.throw(409, 'cart is empty');
186
+
187
+ const lockedCartItems: typeof cartItems = [];
188
+ for (const item of cartItems) {
189
+ const lockedItem = await this.scope.model.cartItem.getByIdForUpdate(item.id);
190
+ if (lockedItem && String(lockedItem.cartId) === String(cart.id))
191
+ lockedCartItems.push(lockedItem);
192
+ }
193
+ if (lockedCartItems.length === 0) this.app.throw(409, 'cart is empty');
194
+
195
+ const preparedLines: IPreparedOrderLine[] = [];
196
+ for (const [index, item] of lockedCartItems.entries()) {
197
+ preparedLines.push(
198
+ await this._prepareLine({ skuId: item.skuId, quantity: item.quantity }, index),
199
+ );
200
+ }
201
+ const address = await this.$scope.commerceMember.model.address.get({
202
+ id: command.addressId,
203
+ userId,
204
+ });
205
+ if (!address) this.app.throw(404, 'Address not found');
206
+
207
+ const { order } = await this._createOrder({
208
+ userId,
209
+ address,
210
+ correlationId: command.correlationId,
211
+ couponGrantId: command.couponGrantId,
212
+ preparedLines,
213
+ reason: 'checkout created',
214
+ });
215
+ const paymentAttempt = await this.$scope.commercePayment.service.paymentAttempt.create({
216
+ orderId: order.id,
217
+ userId,
218
+ currency: order.currency,
219
+ amountCents: order.payableTotalCents,
220
+ correlationId: `${command.correlationId}:payment`,
221
+ });
222
+ await this._appendAudit({
223
+ orderId: order.id,
224
+ operation: 'created',
225
+ toState: 'awaiting_payment',
226
+ correlationId: command.correlationId,
227
+ reason: 'checkout created',
228
+ });
229
+ for (const item of lockedCartItems) {
230
+ await this.scope.model.cartItem.deleteById(item.id);
231
+ }
232
+ return {
233
+ orderId: order.id,
234
+ paymentAttemptId: paymentAttempt.id,
235
+ state: 'awaiting_payment',
236
+ paymentAttemptState: 'created',
237
+ currency: order.currency,
238
+ payableTotalCents: order.payableTotalCents,
239
+ reservationExpiresAt: order.reservationExpiresAt,
240
+ };
241
+ }
242
+
243
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
244
+ @Core.retryable(serializationRetryOptions)
245
+ async expireIfDue(orderId: TableIdentity, now = new Date()): Promise<boolean> {
246
+ const order = await this.scope.model.order.getByIdForUpdate(orderId);
247
+ if (!order || order.state !== 'awaiting_payment' || order.reservationExpiresAt > now)
248
+ return false;
249
+
250
+ if (order.couponSnapshot) {
251
+ await this.$scope.commercePromotion.service.coupon.release({
252
+ couponGrantId: order.couponSnapshot.couponGrantId,
253
+ orderId: order.id,
254
+ correlationId: `${order.correlationId}:expiry:coupon`,
255
+ reason: 'unpaid order expired',
256
+ });
257
+ }
258
+ const lines = await this.scope.model.orderLine.select({
259
+ where: { orderId: order.id },
260
+ orders: [['skuId', 'asc']],
261
+ });
262
+ for (const line of lines) {
263
+ const reservation = await this.scope.model.stockReservation.get({ orderLineId: line.id });
264
+ if (reservation) {
265
+ await this.scope.service.stockBalance.release({
266
+ reservationId: reservation.id,
267
+ reason: 'unpaid order expired',
268
+ });
269
+ }
270
+ }
271
+ await this.$scope.commercePayment.service.paymentAttempt.cancel(order.id);
272
+ await this.scope.model.order.updateById(order.id, { state: 'expired' });
273
+ await this._appendAudit({
274
+ orderId: order.id,
275
+ operation: 'expired',
276
+ fromState: 'awaiting_payment',
277
+ toState: 'expired',
278
+ correlationId: `${order.correlationId}:expiry`,
279
+ reason: 'unpaid order expired',
280
+ });
281
+ return true;
282
+ }
283
+
284
+ async viewSnapshot(id: TableIdentity): Promise<IOrderSnapshotCreateResult | undefined> {
285
+ const order = await this.scope.model.order.get({
286
+ id,
287
+ userId: this.bean.passport.currentUser!.id,
288
+ });
289
+ if (!order) return undefined;
290
+ return await this._viewSnapshot(order);
291
+ }
292
+
293
+ private async _checkoutResult(order: EntityOrder): Promise<DtoCheckoutResult> {
294
+ const paymentAttempt = await this.$scope.commercePayment.model.paymentAttempt.get({
295
+ orderId: order.id,
296
+ });
297
+ if (!paymentAttempt || paymentAttempt.state !== 'created') {
298
+ this.app.throw(409, 'checkout payment attempt is no longer available');
299
+ }
300
+ return {
301
+ orderId: order.id,
302
+ paymentAttemptId: paymentAttempt.id,
303
+ state: 'awaiting_payment',
304
+ paymentAttemptState: 'created',
305
+ currency: order.currency,
306
+ payableTotalCents: order.payableTotalCents,
307
+ reservationExpiresAt: order.reservationExpiresAt,
308
+ };
309
+ }
310
+
311
+ private async _createOrder({
312
+ userId,
313
+ address,
314
+ correlationId,
315
+ couponGrantId,
316
+ preparedLines,
317
+ reason,
318
+ }: {
319
+ userId: TableIdentity;
320
+ address: {
321
+ id: TableIdentity;
322
+ recipientName: string;
323
+ phone: string;
324
+ countryCode: string;
325
+ region: string;
326
+ city: string;
327
+ postalCode: string;
328
+ addressLine1: string;
329
+ addressLine2?: string;
330
+ };
331
+ correlationId: string;
332
+ couponGrantId?: TableIdentity;
333
+ preparedLines: IPreparedOrderLine[];
334
+ reason: string;
335
+ }): Promise<IOrderSnapshotCreateResult> {
336
+ const eligibleSubtotalCents = preparedLines.reduce((total, line) => {
337
+ const nextTotal = total + line.eligibleSubtotalCents;
338
+ if (!Number.isSafeInteger(nextTotal) || nextTotal > maxOrderCents) {
339
+ this.app.throw(400, 'order total exceeds the supported range');
340
+ }
341
+ return nextTotal;
342
+ }, 0);
343
+ const order = await this.scope.model.order.insert({
344
+ userId,
345
+ addressId: address.id,
346
+ correlationId,
347
+ addressSnapshot: this._toAddressSnapshot(address),
348
+ state: 'awaiting_payment',
349
+ currency: 'USD',
350
+ eligibleSubtotalCents,
351
+ discountCents: 0,
352
+ payableTotalCents: eligibleSubtotalCents,
353
+ reservationExpiresAt: new Date(Date.now() + 30 * 60 * 1000),
354
+ });
355
+ if (couponGrantId) {
356
+ const reservation = await this.$scope.commercePromotion.service.coupon.reserve({
357
+ couponGrantId,
358
+ userId,
359
+ orderId: order.id,
360
+ eligibleSubtotalCents,
361
+ currency: 'USD',
362
+ correlationId: `${correlationId}:coupon`,
363
+ reason,
364
+ });
365
+ const couponGrant = reservation.couponGrant;
366
+ const couponSnapshot: DtoOrderCouponSnapshot = {
367
+ couponGrantId: couponGrant.id,
368
+ couponTemplateId: couponGrant.templateId,
369
+ couponCode: couponGrant.couponCode,
370
+ templateName: couponGrant.templateNameSnapshot,
371
+ currency: couponGrant.currencySnapshot,
372
+ fixedDiscountCents: couponGrant.discountCentsSnapshot,
373
+ minSpendCents: couponGrant.minSpendCentsSnapshot,
374
+ appliedDiscountCents: reservation.discountCents,
375
+ };
376
+ await this.scope.model.order.updateById(order.id, {
377
+ couponSnapshot,
378
+ discountCents: reservation.discountCents,
379
+ payableTotalCents: eligibleSubtotalCents - reservation.discountCents,
380
+ });
381
+ order.couponSnapshot = couponSnapshot;
382
+ order.discountCents = reservation.discountCents;
383
+ order.payableTotalCents = eligibleSubtotalCents - reservation.discountCents;
384
+ }
385
+ const lines: EntityOrderLine[] = [];
386
+ for (const line of preparedLines) {
387
+ const orderLine = await this.scope.model.orderLine.insert({
388
+ orderId: order.id,
389
+ skuId: line.skuId,
390
+ productId: line.productId,
391
+ skuCodeSnapshot: line.skuCodeSnapshot,
392
+ titleSnapshot: line.titleSnapshot,
393
+ skuAttributesSnapshot: line.skuAttributesSnapshot,
394
+ unitPriceCents: line.unitPriceCents,
395
+ quantity: line.quantity,
396
+ eligibleSubtotalCents: line.eligibleSubtotalCents,
397
+ lineTotalCents: line.eligibleSubtotalCents,
398
+ });
399
+ await this.scope.service.stockBalance.reserve({
400
+ skuId: orderLine.skuId,
401
+ orderLineId: orderLine.id,
402
+ quantity: orderLine.quantity,
403
+ correlationId: `${correlationId}:line:${line.index}`,
404
+ reason,
405
+ });
406
+ lines.push(orderLine);
407
+ }
408
+ return { order, lines };
409
+ }
410
+
411
+ private async _appendAudit({
412
+ orderId,
413
+ operation,
414
+ fromState,
415
+ toState,
416
+ correlationId,
417
+ reason,
418
+ }: {
419
+ orderId: TableIdentity;
420
+ operation: 'created' | 'expired';
421
+ fromState?: 'awaiting_payment' | 'expired';
422
+ toState: 'awaiting_payment' | 'expired';
423
+ correlationId: string;
424
+ reason: string;
425
+ }) {
426
+ await this.scope.model.orderAudit.insert({
427
+ orderId,
428
+ operation,
429
+ fromState,
430
+ toState,
431
+ actorId: this.bean.passport.currentUser?.anonymous
432
+ ? undefined
433
+ : this.bean.passport.currentUser?.id,
434
+ correlationId,
435
+ reason,
436
+ });
437
+ }
438
+
439
+ private async _viewSnapshot(order: EntityOrder): Promise<IOrderSnapshotCreateResult> {
440
+ const lines = await this.scope.model.orderLine.select({
441
+ where: { orderId: order.id },
442
+ orders: [['id', 'asc']],
443
+ });
444
+ return { order, lines };
445
+ }
446
+
447
+ private async _assertExistingSnapshotMatchesCommand(
448
+ order: EntityOrder,
449
+ command: IOrderSnapshotCreateCommand,
450
+ ) {
451
+ const lines = await this.scope.model.orderLine.select({
452
+ where: { orderId: order.id },
453
+ orders: [['id', 'asc']],
454
+ });
455
+ if (
456
+ String(order.addressId) !== String(command.addressId) ||
457
+ String(order.couponSnapshot?.couponGrantId ?? '') !== String(command.couponGrantId ?? '') ||
458
+ lines.length !== command.lines.length ||
459
+ lines.some(
460
+ (line, index) =>
461
+ String(line.skuId) !== String(command.lines[index].skuId) ||
462
+ line.quantity !== command.lines[index].quantity,
463
+ )
464
+ ) {
465
+ this.app.throw(409, 'order snapshot correlation conflicts with an existing order');
466
+ }
467
+ }
468
+
469
+ private async _prepareLine(command: IOrderSnapshotLineCommand, index = 0) {
470
+ if (!Number.isInteger(command.quantity) || command.quantity <= 0) {
471
+ this.app.throw(400, 'order line quantity must be a positive integer');
472
+ }
473
+ const sku = await this.$scope.commerceCatalog.model.sku.getById(command.skuId, {
474
+ include: {
475
+ product: {
476
+ include: {
477
+ category: true,
478
+ },
479
+ },
480
+ },
481
+ });
482
+ if (!sku || sku.lifecycle !== 'active') {
483
+ this.app.throw(404, 'SKU not found');
484
+ }
485
+ if (!sku.product?.published || !sku.product.category?.published) {
486
+ this.app.throw(409, 'SKU is not sellable');
487
+ }
488
+ const eligibleSubtotalCents = sku.priceCents * command.quantity;
489
+ if (!Number.isSafeInteger(eligibleSubtotalCents) || eligibleSubtotalCents > maxOrderCents) {
490
+ this.app.throw(400, 'order line total exceeds the supported range');
491
+ }
492
+ return {
493
+ index,
494
+ skuId: sku.id,
495
+ productId: sku.product.id,
496
+ skuCodeSnapshot: sku.code,
497
+ titleSnapshot: sku.product.title,
498
+ skuAttributesSnapshot: sku.attributes.map(
499
+ attribute =>
500
+ ({
501
+ name: attribute.name,
502
+ value: attribute.value,
503
+ }) satisfies DtoOrderLineSkuAttributeSnapshot,
504
+ ),
505
+ unitPriceCents: sku.priceCents,
506
+ quantity: command.quantity,
507
+ eligibleSubtotalCents,
508
+ };
509
+ }
510
+
511
+ private _toAddressSnapshot(address: {
512
+ recipientName: string;
513
+ phone: string;
514
+ countryCode: string;
515
+ region: string;
516
+ city: string;
517
+ postalCode: string;
518
+ addressLine1: string;
519
+ addressLine2?: string;
520
+ }): DtoOrderAddressSnapshot {
521
+ return {
522
+ recipientName: address.recipientName,
523
+ phone: address.phone,
524
+ countryCode: address.countryCode,
525
+ region: address.region,
526
+ city: address.city,
527
+ postalCode: address.postalCode,
528
+ addressLine1: address.addressLine1,
529
+ addressLine2: address.addressLine2,
530
+ };
531
+ }
532
+
533
+ private _assertCheckoutCommand(command: DtoCheckoutCreate) {
534
+ if (!command.correlationId.trim()) {
535
+ this.app.throw(400, 'checkout correlationId is required');
536
+ }
537
+ if (command.correlationId.length > 80) {
538
+ this.app.throw(400, 'checkout correlationId is too long');
539
+ }
540
+ }
541
+
542
+ private _assertCommand(command: IOrderSnapshotCreateCommand) {
543
+ if (!command.correlationId.trim()) {
544
+ this.app.throw(400, 'order snapshot correlationId is required');
545
+ }
546
+ if (command.correlationId.length > 70) {
547
+ this.app.throw(400, 'order snapshot correlationId is too long');
548
+ }
549
+ if (command.lines.length === 0) {
550
+ this.app.throw(400, 'order snapshot requires at least one line');
551
+ }
552
+ if (new Set(command.lines.map(line => String(line.skuId))).size !== command.lines.length) {
553
+ this.app.throw(400, 'order snapshot cannot contain duplicate SKUs');
554
+ }
555
+ }
556
+ }
@@ -17,6 +17,7 @@ import type { ModelStockBalance } from '../model/stockBalance.ts';
17
17
 
18
18
  export interface IStockReservationCommand {
19
19
  skuId: TableIdentity;
20
+ orderLineId?: TableIdentity;
20
21
  quantity: number;
21
22
  correlationId: string;
22
23
  reason: string;
@@ -27,6 +28,16 @@ export interface IStockReservationTransition {
27
28
  reason: string;
28
29
  }
29
30
 
31
+ const serializationRetryOptions = {
32
+ retries: 1,
33
+ factor: 1,
34
+ minTimeout: 0,
35
+ maxTimeout: 0,
36
+ randomize: false,
37
+ errorCodes: ['40001'],
38
+ ownerOnly: true,
39
+ };
40
+
30
41
  @Service()
31
42
  export class ServiceStockBalance extends BeanBase {
32
43
  async select(params?: IQueryParams<ModelStockBalance>): Promise<DtoStockBalanceSelectRes> {
@@ -47,6 +58,11 @@ export class ServiceStockBalance extends BeanBase {
47
58
  let stockBalance = await this.scope.model.stockBalance.getForUpdate({
48
59
  skuId: stockAdjust.skuId,
49
60
  });
61
+ const priorBalance = stockBalance ?? {
62
+ onHand: 0,
63
+ reserved: 0,
64
+ available: 0,
65
+ };
50
66
  if (!stockBalance) {
51
67
  if (stockAdjust.delta < 0) {
52
68
  throw new Error('stock adjustment would make balance negative');
@@ -79,6 +95,7 @@ export class ServiceStockBalance extends BeanBase {
79
95
 
80
96
  await this._appendAudit({
81
97
  stockBalance,
98
+ priorBalance,
82
99
  delta: stockAdjust.delta,
83
100
  operation: 'adjust',
84
101
  reason: stockAdjust.reason,
@@ -88,6 +105,7 @@ export class ServiceStockBalance extends BeanBase {
88
105
  }
89
106
 
90
107
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
108
+ @Core.retryable(serializationRetryOptions)
91
109
  async reserve(command: IStockReservationCommand): Promise<EntityStockReservation> {
92
110
  this._assertReservationCommand(command);
93
111
  const sku = await this.$scope.commerceCatalog.model.sku.getById(command.skuId, {
@@ -116,6 +134,7 @@ export class ServiceStockBalance extends BeanBase {
116
134
  if (existingReservation) {
117
135
  if (
118
136
  String(existingReservation.skuId) !== String(command.skuId) ||
137
+ String(existingReservation.orderLineId ?? '') !== String(command.orderLineId ?? '') ||
119
138
  existingReservation.quantity !== command.quantity
120
139
  ) {
121
140
  this.app.throw(409, 'stock reservation correlation conflicts with an existing reservation');
@@ -129,16 +148,19 @@ export class ServiceStockBalance extends BeanBase {
129
148
  const reservation = await this.scope.model.stockReservation.insert({
130
149
  stockBalanceId: stockBalance.id,
131
150
  skuId: command.skuId,
151
+ orderLineId: command.orderLineId,
132
152
  quantity: command.quantity,
133
153
  state: 'reserved',
134
154
  correlationId: command.correlationId,
135
155
  });
156
+ const priorBalance = stockBalance;
136
157
  const updatedBalance = await this._updateBalance(stockBalance, {
137
158
  onHand: stockBalance.onHand,
138
159
  reserved: stockBalance.reserved + command.quantity,
139
160
  });
140
161
  await this._appendAudit({
141
162
  stockBalance: updatedBalance,
163
+ priorBalance,
142
164
  stockReservationId: reservation.id,
143
165
  delta: -command.quantity,
144
166
  operation: 'reserve',
@@ -218,6 +240,7 @@ export class ServiceStockBalance extends BeanBase {
218
240
  if (!stockBalance) {
219
241
  this.app.throw(404, 'stock balance not found');
220
242
  }
243
+ const priorBalance = stockBalance;
221
244
  const updatedBalance = await this._updateBalance(
222
245
  stockBalance,
223
246
  calculateBalance(stockBalance, reservation),
@@ -228,6 +251,7 @@ export class ServiceStockBalance extends BeanBase {
228
251
  targetState === 'consumed' ? 'consume' : targetState === 'released' ? 'release' : 'restore';
229
252
  await this._appendAudit({
230
253
  stockBalance: updatedBalance,
254
+ priorBalance,
231
255
  stockReservationId: reservation.id,
232
256
  delta:
233
257
  operation === 'consume'
@@ -264,6 +288,7 @@ export class ServiceStockBalance extends BeanBase {
264
288
 
265
289
  private async _appendAudit({
266
290
  stockBalance,
291
+ priorBalance,
267
292
  stockReservationId,
268
293
  delta,
269
294
  operation,
@@ -271,6 +296,7 @@ export class ServiceStockBalance extends BeanBase {
271
296
  correlationId,
272
297
  }: {
273
298
  stockBalance: EntityStockBalance;
299
+ priorBalance: Pick<EntityStockBalance, 'onHand' | 'reserved' | 'available'>;
274
300
  stockReservationId?: TableIdentity;
275
301
  delta: number;
276
302
  operation: 'adjust' | 'reserve' | 'consume' | 'release' | 'restore';
@@ -281,10 +307,16 @@ export class ServiceStockBalance extends BeanBase {
281
307
  stockBalanceId: stockBalance.id,
282
308
  skuId: stockBalance.skuId,
283
309
  stockReservationId,
310
+ actorId: this.bean.passport.currentUser?.anonymous
311
+ ? undefined
312
+ : this.bean.passport.currentUser?.id,
284
313
  operation,
285
314
  delta,
286
315
  reason,
287
316
  correlationId,
317
+ priorOnHand: priorBalance.onHand,
318
+ priorReserved: priorBalance.reserved,
319
+ priorAvailable: priorBalance.available,
288
320
  onHand: stockBalance.onHand,
289
321
  reserved: stockBalance.reserved,
290
322
  available: stockBalance.available,