cabloy 5.1.113 → 5.1.115

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 (141) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +4 -0
  3. package/.github/workflows/vona-test-mysql.yml +12 -0
  4. package/.github/workflows/vona-test-pg.yml +5 -1
  5. package/CHANGELOG.md +20 -0
  6. package/CLAUDE.md +4 -0
  7. package/cabloy-docs/backend/internal-aop-guide.md +35 -0
  8. package/cabloy-docs/backend/unit-testing.md +72 -1
  9. package/package.json +1 -1
  10. package/vona/packages-cli/cli/package.json +1 -1
  11. package/vona/packages-cli/cli-set-api/package.json +1 -1
  12. package/vona/packages-cli/cli-set-api/toolsIsolate/test.ts +26 -13
  13. package/vona/packages-utils/compose/package.json +1 -1
  14. package/vona/packages-utils/compose/src/index.ts +19 -11
  15. package/vona/packages-vona/vona/package.json +1 -1
  16. package/vona/packages-vona/vona-core/package.json +1 -1
  17. package/vona/packages-vona/vona-core/src/lib/bean/beanContainer.ts +15 -3
  18. package/vona/packages-vona/vona-mock/package.json +1 -1
  19. package/vona/pnpm-lock.yaml +143 -12
  20. package/vona/pnpm-workspace.yaml +1 -1
  21. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +8 -0
  22. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.version.ts +1 -0
  23. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
  24. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
  25. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuAttribute.tsx +17 -0
  26. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/sku.tsx +10 -1
  27. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +1 -1
  28. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +6 -1
  29. package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
  30. package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +228 -0
  31. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +15 -0
  32. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +35 -0
  33. package/vona/src/suite/a-commerce/modules/commerce-payment/src/dto/paymentAttemptView.tsx +26 -0
  34. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAttempt.tsx +34 -0
  35. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/paymentAttempt.ts +10 -0
  36. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +41 -0
  37. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +33 -0
  38. package/vona/src/suite/a-commerce/modules/commerce-promotion/package.json +4 -2
  39. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +533 -2
  40. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/locales.ts +18 -0
  41. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +24 -0
  42. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.version.ts +137 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/ssrMenu.couponTemplate.ts +30 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +37 -0
  45. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +37 -0
  46. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/coupon.ts +37 -0
  47. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/couponTemplate.ts +65 -0
  48. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +22 -0
  49. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponMineItem.tsx +32 -0
  50. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +43 -0
  51. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectReq.tsx +28 -0
  52. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectRes.tsx +11 -0
  53. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectResItem.tsx +39 -0
  54. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +19 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateView.tsx +25 -0
  56. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponAudit.tsx +73 -0
  57. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponGrant.tsx +83 -0
  58. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +82 -0
  59. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/index.ts +1 -0
  60. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponAudit.ts +10 -0
  61. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponGrant.ts +10 -0
  62. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponTemplate.ts +10 -0
  63. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +397 -0
  64. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/couponTemplate.ts +52 -0
  65. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +162 -0
  66. package/vona/src/suite/a-commerce/modules/commerce-seed/src/bean/meta.version.ts +8 -10
  67. package/vona/src/suite/a-commerce/modules/commerce-trade/package.json +4 -1
  68. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +277 -0
  69. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/bean.scheduleOrderExpiry.ts +26 -0
  70. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +9 -0
  71. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +75 -0
  72. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +11 -2
  73. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +11 -2
  74. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +19 -0
  75. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +19 -0
  76. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutResult.tsx +32 -0
  77. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderAddressSnapshot.tsx +33 -0
  78. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderCouponSnapshot.tsx +35 -0
  79. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderLineSkuAttributeSnapshot.tsx +15 -0
  80. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/order.tsx +90 -0
  81. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +36 -0
  82. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderLine.tsx +52 -0
  83. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockReservation.tsx +4 -1
  84. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +15 -0
  85. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderAudit.ts +10 -0
  86. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderLine.ts +10 -0
  87. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +556 -0
  88. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +15 -20
  89. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +8 -3
  90. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +158 -0
  91. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +203 -0
  92. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +163 -0
  93. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +11 -6
  94. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +11 -6
  95. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
  96. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +10 -7
  97. package/vona/src/suite-vendor/a-image/package.json +1 -1
  98. package/vona/src/suite-vendor/a-vona/modules/a-aspect/package.json +1 -1
  99. package/vona/src/suite-vendor/a-vona/modules/a-aspect/src/types/aopMethod.ts +16 -3
  100. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +4 -2
  101. package/vona/src/suite-vendor/a-vona/modules/a-core/src/bean/bean.core.ts +6 -0
  102. package/vona/src/suite-vendor/a-vona/modules/a-core/src/index.ts +1 -0
  103. package/vona/src/suite-vendor/a-vona/modules/a-core/src/lib/core.ts +6 -0
  104. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/fetch.ts +7 -0
  105. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/index.ts +1 -0
  106. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/lib/utils.ts +5 -3
  108. package/vona/src/suite-vendor/a-vona/modules/a-retryable/package.json +50 -0
  109. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/index.ts +55 -0
  110. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/this.ts +2 -0
  111. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/bean/aopMethod.retryable.ts +45 -0
  112. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/index.ts +1 -0
  113. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.build.json +11 -0
  114. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.json +7 -0
  115. package/vona/src/suite-vendor/a-vona/modules/a-vona/package.json +2 -1
  116. package/vona/src/suite-vendor/a-vona/package.json +2 -1
  117. package/vona/src/suite-vendor/a-vona/tsconfig.json +3 -0
  118. package/zova/packages-utils/zova-jsx/package.json +3 -3
  119. package/zova/packages-zova/zova/package.json +3 -3
  120. package/zova/packages-zova/zova-core/package.json +2 -2
  121. package/zova/pnpm-lock.yaml +31 -31
  122. package/zova/src/suite/a-commerce/modules/commerce-promotion/cli/openapi.config.ts +9 -0
  123. package/zova/src/suite/a-commerce/modules/commerce-promotion/package.json +2 -1
  124. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +115 -1
  125. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/commercePromotionCoupon.ts +24 -0
  126. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/baseURL.ts +5 -0
  127. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/index.ts +3 -0
  128. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +478 -0
  129. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +8187 -0
  130. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/apiSchema/commercePromotionCoupon.ts +13 -0
  131. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/model/coupon.ts +19 -0
  132. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
  133. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +30 -0
  134. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +27 -0
  135. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +92 -52
  136. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +1389 -848
  137. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +13 -0
  138. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +13 -0
  139. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +3 -3
  140. package/zova/src/suite-vendor/a-zova/package.json +2 -2
  141. /package/vona/patches/{zova-core@5.1.78.patch → zova-core@5.1.79.patch} +0 -0
@@ -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> {
@@ -93,23 +104,9 @@ export class ServiceStockBalance extends BeanBase {
93
104
  return stockBalance;
94
105
  }
95
106
 
107
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
108
+ @Core.retryable(serializationRetryOptions)
96
109
  async reserve(command: IStockReservationCommand): Promise<EntityStockReservation> {
97
- const transaction = this.bean.database.current.transaction;
98
- if (transaction.inTransaction) {
99
- return await this._reserve(command);
100
- }
101
- for (let attempt = 0; ; attempt++) {
102
- try {
103
- return await transaction.begin(() => this._reserve(command), {
104
- isolationLevel: 'SERIALIZABLE',
105
- });
106
- } catch (error) {
107
- if (!this._isSerializationFailure(error) || attempt >= 1) throw error;
108
- }
109
- }
110
- }
111
-
112
- private async _reserve(command: IStockReservationCommand): Promise<EntityStockReservation> {
113
110
  this._assertReservationCommand(command);
114
111
  const sku = await this.$scope.commerceCatalog.model.sku.getById(command.skuId, {
115
112
  include: {
@@ -137,6 +134,7 @@ export class ServiceStockBalance extends BeanBase {
137
134
  if (existingReservation) {
138
135
  if (
139
136
  String(existingReservation.skuId) !== String(command.skuId) ||
137
+ String(existingReservation.orderLineId ?? '') !== String(command.orderLineId ?? '') ||
140
138
  existingReservation.quantity !== command.quantity
141
139
  ) {
142
140
  this.app.throw(409, 'stock reservation correlation conflicts with an existing reservation');
@@ -150,6 +148,7 @@ export class ServiceStockBalance extends BeanBase {
150
148
  const reservation = await this.scope.model.stockReservation.insert({
151
149
  stockBalanceId: stockBalance.id,
152
150
  skuId: command.skuId,
151
+ orderLineId: command.orderLineId,
153
152
  quantity: command.quantity,
154
153
  state: 'reserved',
155
154
  correlationId: command.correlationId,
@@ -324,10 +323,6 @@ export class ServiceStockBalance extends BeanBase {
324
323
  });
325
324
  }
326
325
 
327
- private _isSerializationFailure(error: unknown) {
328
- return (error as { code?: unknown })?.code === '40001';
329
- }
330
-
331
326
  private _assertReservationCommand(command: IStockReservationCommand) {
332
327
  if (!Number.isInteger(command.quantity) || command.quantity <= 0) {
333
328
  this.app.throw(400, 'stock reservation quantity must be a positive integer');
@@ -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`, {