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.
- package/.cabloy-version +1 -1
- package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +4 -0
- package/.github/workflows/vona-test-mysql.yml +12 -0
- package/.github/workflows/vona-test-pg.yml +5 -1
- package/CHANGELOG.md +20 -0
- package/CLAUDE.md +4 -0
- package/cabloy-docs/backend/internal-aop-guide.md +35 -0
- package/cabloy-docs/backend/unit-testing.md +72 -1
- package/package.json +1 -1
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/toolsIsolate/test.ts +26 -13
- package/vona/packages-utils/compose/package.json +1 -1
- package/vona/packages-utils/compose/src/index.ts +19 -11
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/bean/beanContainer.ts +15 -3
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +143 -12
- package/vona/pnpm-workspace.yaml +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +8 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.version.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuAttribute.tsx +17 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/sku.tsx +10 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +6 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +228 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +15 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +35 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/dto/paymentAttemptView.tsx +26 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAttempt.tsx +34 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/paymentAttempt.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +41 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +33 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/package.json +4 -2
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +533 -2
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/locales.ts +18 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +24 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.version.ts +137 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/ssrMenu.couponTemplate.ts +30 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +37 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +37 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/coupon.ts +37 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/couponTemplate.ts +65 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +22 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponMineItem.tsx +32 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +43 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectReq.tsx +28 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectRes.tsx +11 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectResItem.tsx +39 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +19 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateView.tsx +25 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponAudit.tsx +73 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponGrant.tsx +83 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +82 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/index.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponAudit.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponGrant.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponTemplate.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +397 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/couponTemplate.ts +52 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +162 -0
- package/vona/src/suite/a-commerce/modules/commerce-seed/src/bean/meta.version.ts +8 -10
- package/vona/src/suite/a-commerce/modules/commerce-trade/package.json +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +277 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/bean.scheduleOrderExpiry.ts +26 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +9 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +75 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +11 -2
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +11 -2
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +19 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +19 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutResult.tsx +32 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderAddressSnapshot.tsx +33 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderCouponSnapshot.tsx +35 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderLineSkuAttributeSnapshot.tsx +15 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/order.tsx +90 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +36 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderLine.tsx +52 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockReservation.tsx +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +15 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderAudit.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderLine.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +556 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +15 -20
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +8 -3
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +158 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +203 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +163 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +11 -6
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +11 -6
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +10 -7
- package/vona/src/suite-vendor/a-image/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-aspect/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-aspect/src/types/aopMethod.ts +16 -3
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +4 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/bean/bean.core.ts +6 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/lib/core.ts +6 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/fetch.ts +7 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/lib/utils.ts +5 -3
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/package.json +50 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/index.ts +55 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/this.ts +2 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/bean/aopMethod.retryable.ts +45 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.build.json +11 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.json +7 -0
- package/vona/src/suite-vendor/a-vona/modules/a-vona/package.json +2 -1
- package/vona/src/suite-vendor/a-vona/package.json +2 -1
- package/vona/src/suite-vendor/a-vona/tsconfig.json +3 -0
- package/zova/packages-utils/zova-jsx/package.json +3 -3
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/pnpm-lock.yaml +31 -31
- package/zova/src/suite/a-commerce/modules/commerce-promotion/cli/openapi.config.ts +9 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/package.json +2 -1
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +115 -1
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/commercePromotionCoupon.ts +24 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/baseURL.ts +5 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/index.ts +3 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +478 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +8187 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/apiSchema/commercePromotionCoupon.ts +13 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/model/coupon.ts +19 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +30 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +27 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +92 -52
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +1389 -848
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +13 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +13 -0
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +3 -3
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
- /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 =
|
|
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 =
|
|
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`, {
|