cabloy 5.1.115 → 5.1.116

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 (31) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/vona-test-mysql.yml +6 -10
  3. package/.github/workflows/vona-test-pg.yml +6 -10
  4. package/CHANGELOG.md +14 -0
  5. package/cabloy-docs/backend/orm-mutation-guide.md +36 -10
  6. package/cabloy-docs/backend/orm-select-guide.md +15 -10
  7. package/package.json +1 -1
  8. package/vona/packages-vona/vona/package.json +1 -1
  9. package/vona/pnpm-lock.yaml +8 -124
  10. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/sku.test.ts +1 -1
  11. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +4 -1
  12. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +1 -1
  13. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +4 -1
  14. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +85 -2
  15. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/cart.ts +14 -0
  16. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +42 -2
  17. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +4 -3
  18. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +160 -80
  19. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +255 -1
  20. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutTransaction.test.ts +208 -0
  21. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +28 -4
  22. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +90 -8
  23. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +15 -1
  24. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +20 -2
  25. package/vona/src/suite/a-commerce/modules/commerce-trade/test/testLock.ts +11 -0
  26. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  27. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts +3 -0
  28. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_utils.ts +1 -1
  29. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  30. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +40 -32
  31. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +638 -633
@@ -20,7 +20,12 @@ const serializationRetryOptions = {
20
20
  minTimeout: 0,
21
21
  maxTimeout: 0,
22
22
  randomize: false,
23
- errorCodes: ['40001'],
23
+ errorCodes: ['40001', 'ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'],
24
+ };
25
+
26
+ const checkoutSerializationRetryOptions = {
27
+ ...serializationRetryOptions,
28
+ retries: 3,
24
29
  };
25
30
 
26
31
  export interface IOrderSnapshotLineCommand {
@@ -40,6 +45,16 @@ export interface IOrderSnapshotCreateResult {
40
45
  lines: EntityOrderLine[];
41
46
  }
42
47
 
48
+ export type CheckoutFailureStage =
49
+ | 'afterOrderSnapshot'
50
+ | 'afterCouponReservation'
51
+ | 'afterStockReservation'
52
+ | 'afterPaymentAttempt'
53
+ | 'afterOrderAudit'
54
+ | 'afterCartMutation';
55
+
56
+ type CheckoutStageCallback = (stage: CheckoutFailureStage) => void | Promise<void>;
57
+
43
58
  interface IPreparedOrderLine {
44
59
  index: number;
45
60
  skuId: TableIdentity;
@@ -157,8 +172,24 @@ export class ServiceOrder extends BeanBase {
157
172
  }
158
173
 
159
174
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
160
- @Core.retryable(serializationRetryOptions)
175
+ @Core.retryable(checkoutSerializationRetryOptions)
161
176
  async checkout(command: DtoCheckoutCreate): Promise<DtoCheckoutResult> {
177
+ return await this._checkout(command);
178
+ }
179
+
180
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
181
+ @Core.retryable(serializationRetryOptions)
182
+ async checkoutForTest(
183
+ command: DtoCheckoutCreate,
184
+ onStage: CheckoutStageCallback,
185
+ ): Promise<DtoCheckoutResult> {
186
+ return await this._checkout(command, onStage);
187
+ }
188
+
189
+ private async _checkout(
190
+ command: DtoCheckoutCreate,
191
+ onStage?: CheckoutStageCallback,
192
+ ): Promise<DtoCheckoutResult> {
162
193
  this._assertCheckoutCommand(command);
163
194
  const userId = this.bean.passport.currentUser!.id;
164
195
  const existingOrder = await this.scope.model.order.getForUpdate({
@@ -211,6 +242,7 @@ export class ServiceOrder extends BeanBase {
211
242
  couponGrantId: command.couponGrantId,
212
243
  preparedLines,
213
244
  reason: 'checkout created',
245
+ onStage,
214
246
  });
215
247
  const paymentAttempt = await this.$scope.commercePayment.service.paymentAttempt.create({
216
248
  orderId: order.id,
@@ -219,6 +251,7 @@ export class ServiceOrder extends BeanBase {
219
251
  amountCents: order.payableTotalCents,
220
252
  correlationId: `${command.correlationId}:payment`,
221
253
  });
254
+ await onStage?.('afterPaymentAttempt');
222
255
  await this._appendAudit({
223
256
  orderId: order.id,
224
257
  operation: 'created',
@@ -226,9 +259,11 @@ export class ServiceOrder extends BeanBase {
226
259
  correlationId: command.correlationId,
227
260
  reason: 'checkout created',
228
261
  });
262
+ await onStage?.('afterOrderAudit');
229
263
  for (const item of lockedCartItems) {
230
264
  await this.scope.model.cartItem.deleteById(item.id);
231
265
  }
266
+ await onStage?.('afterCartMutation');
232
267
  return {
233
268
  orderId: order.id,
234
269
  paymentAttemptId: paymentAttempt.id,
@@ -315,6 +350,7 @@ export class ServiceOrder extends BeanBase {
315
350
  couponGrantId,
316
351
  preparedLines,
317
352
  reason,
353
+ onStage,
318
354
  }: {
319
355
  userId: TableIdentity;
320
356
  address: {
@@ -332,6 +368,7 @@ export class ServiceOrder extends BeanBase {
332
368
  couponGrantId?: TableIdentity;
333
369
  preparedLines: IPreparedOrderLine[];
334
370
  reason: string;
371
+ onStage?: CheckoutStageCallback;
335
372
  }): Promise<IOrderSnapshotCreateResult> {
336
373
  const eligibleSubtotalCents = preparedLines.reduce((total, line) => {
337
374
  const nextTotal = total + line.eligibleSubtotalCents;
@@ -352,6 +389,7 @@ export class ServiceOrder extends BeanBase {
352
389
  payableTotalCents: eligibleSubtotalCents,
353
390
  reservationExpiresAt: new Date(Date.now() + 30 * 60 * 1000),
354
391
  });
392
+ await onStage?.('afterOrderSnapshot');
355
393
  if (couponGrantId) {
356
394
  const reservation = await this.$scope.commercePromotion.service.coupon.reserve({
357
395
  couponGrantId,
@@ -381,6 +419,7 @@ export class ServiceOrder extends BeanBase {
381
419
  order.couponSnapshot = couponSnapshot;
382
420
  order.discountCents = reservation.discountCents;
383
421
  order.payableTotalCents = eligibleSubtotalCents - reservation.discountCents;
422
+ await onStage?.('afterCouponReservation');
384
423
  }
385
424
  const lines: EntityOrderLine[] = [];
386
425
  for (const line of preparedLines) {
@@ -403,6 +442,7 @@ export class ServiceOrder extends BeanBase {
403
442
  correlationId: `${correlationId}:line:${line.index}`,
404
443
  reason,
405
444
  });
445
+ await onStage?.('afterStockReservation');
406
446
  lines.push(orderLine);
407
447
  }
408
448
  return { order, lines };
@@ -34,7 +34,7 @@ const serializationRetryOptions = {
34
34
  minTimeout: 0,
35
35
  maxTimeout: 0,
36
36
  randomize: false,
37
- errorCodes: ['40001'],
37
+ errorCodes: ['40001', 'ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'],
38
38
  ownerOnly: true,
39
39
  };
40
40
 
@@ -49,6 +49,7 @@ export class ServiceStockBalance extends BeanBase {
49
49
  }
50
50
 
51
51
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
52
+ @Core.retryable(serializationRetryOptions)
52
53
  async adjustStock(stockAdjust: DtoStockAdjust): Promise<EntityStockBalance> {
53
54
  const sku = await this.$scope.commerceCatalog.model.sku.getById(stockAdjust.skuId);
54
55
  if (!sku) {
@@ -65,7 +66,7 @@ export class ServiceStockBalance extends BeanBase {
65
66
  };
66
67
  if (!stockBalance) {
67
68
  if (stockAdjust.delta < 0) {
68
- throw new Error('stock adjustment would make balance negative');
69
+ this.app.throw(409, 'stock adjustment would make balance negative');
69
70
  }
70
71
  stockBalance = await this.scope.model.stockBalance.insert({
71
72
  skuId: stockAdjust.skuId,
@@ -78,7 +79,7 @@ export class ServiceStockBalance extends BeanBase {
78
79
  const reserved = stockBalance.reserved;
79
80
  const available = onHand - reserved;
80
81
  if (onHand < 0 || reserved < 0 || available < 0) {
81
- throw new Error('stock adjustment would make balance negative');
82
+ this.app.throw(409, 'stock adjustment would make balance negative');
82
83
  }
83
84
  await this.scope.model.stockBalance.updateById(stockBalance.id, {
84
85
  onHand,
@@ -3,11 +3,19 @@ import type { DtoCartAddItem, DtoCartUpdateItem } from 'vona-module-commerce-tra
3
3
  import { catchError } from '@cabloy/utils';
4
4
  import assert from 'node:assert';
5
5
  import { randomUUID } from 'node:crypto';
6
- import { describe, it } from 'node:test';
6
+ import { after, before, describe, it } from 'node:test';
7
7
  import { app } from 'vona-mock';
8
8
 
9
+ import { acquireTestLock } from './testLock.ts';
10
+
9
11
  const actionPath = '/commerce/trade/cart';
10
12
 
13
+ interface ISellableSkuFixture {
14
+ categoryId: number;
15
+ productId: number;
16
+ skuId: number;
17
+ }
18
+
11
19
  function createTestId() {
12
20
  return randomUUID().slice(0, 12);
13
21
  }
@@ -27,12 +35,12 @@ async function performAs(accessToken: string, method: string, path: string, opti
27
35
  }
28
36
 
29
37
  async function registerAndSignin(name: string) {
30
- await app.bean.user.register({ name }, true);
38
+ const user = await app.bean.user.register({ name }, true);
31
39
  const token = await app.bean.passport.signinMock(name as any);
32
- return { token: token.accessToken, user: app.bean.passport.currentUser! };
40
+ return { token: token.accessToken, user };
33
41
  }
34
42
 
35
- async function createSellableSku(suffix: string) {
43
+ async function createSellableSku(suffix: string): Promise<ISellableSkuFixture> {
36
44
  await app.bean.passport.signinMock();
37
45
  try {
38
46
  const categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
@@ -57,13 +65,47 @@ async function createSellableSku(suffix: string) {
57
65
  correlationId: `cart-stock-${suffix}`,
58
66
  },
59
67
  });
60
- return skuId;
68
+ return { categoryId, productId, skuId };
61
69
  } finally {
62
70
  await app.bean.passport.signout();
63
71
  }
64
72
  }
65
73
 
74
+ async function dropSellableSku(fixture: ISellableSkuFixture | undefined) {
75
+ if (!fixture) return;
76
+ const trade = app.scope('commerce-trade');
77
+ const catalog = app.scope('commerce-catalog');
78
+ await trade.model.stockAudit.delete({ skuId: fixture.skuId });
79
+ await trade.model.stockReservation.delete({ skuId: fixture.skuId });
80
+ await trade.model.stockBalance.delete({ skuId: fixture.skuId });
81
+ await catalog.model.sku.delete({ id: fixture.skuId });
82
+ await catalog.model.product.delete({ id: fixture.productId });
83
+ await catalog.model.category.delete({ id: fixture.categoryId });
84
+ }
85
+
86
+ async function dropUser(userId: number | undefined) {
87
+ if (userId === undefined) return;
88
+ const trade = app.scope('commerce-trade');
89
+ const carts = await trade.model.cart.select({ where: { userId } });
90
+ for (const cart of carts) {
91
+ await trade.model.cartItem.delete({ cartId: cart.id });
92
+ await trade.model.cart.delete({ id: cart.id });
93
+ }
94
+ await app.scope('home-user').model.roleUser.delete({ userId });
95
+ await app.bean.user.removeById(userId);
96
+ }
97
+
66
98
  describe('cartOwnership.test.ts', { concurrency: false }, () => {
99
+ let releaseTestLock: (() => void) | undefined;
100
+
101
+ before(async () => {
102
+ releaseTestLock = await acquireTestLock();
103
+ });
104
+
105
+ after(() => {
106
+ releaseTestLock?.();
107
+ });
108
+
67
109
  it('denies anonymous cart actions', async () => {
68
110
  await app.bean.executor.mockCtx(async () => {
69
111
  for (const [method, path, options] of [
@@ -84,96 +126,134 @@ describe('cartOwnership.test.ts', { concurrency: false }, () => {
84
126
  it('derives cart ownership from the authenticated customer and scopes every item action', async () => {
85
127
  await app.bean.executor.mockCtx(async () => {
86
128
  const suffix = createTestId();
87
- const skuId = await createSellableSku(suffix);
88
- const customerA = await registerAndSignin(`cart-a-${suffix}`);
89
- const customerB = await registerAndSignin(`cart-b-${suffix}`);
90
-
91
- const cart = await performAs(customerA.token, 'post', `${actionPath}/items`, {
92
- body: { ...cartItem(skuId, 2), userId: customerB.user.id, iid: 999999 } as any,
93
- });
94
- assert.equal(cart.items.length, 1);
95
- assert.equal(cart.items[0].quantity, 2);
96
- assert.equal(Object.hasOwn(cart, 'userId'), false);
97
- assert.equal(Object.hasOwn(cart, 'iid'), false);
98
- const itemId = cart.items[0].id;
99
-
100
- const persistedCart = await app.scope('commerce-trade').model.cart.getById(cart.id);
101
- assert.equal(String(persistedCart?.userId), String(customerA.user.id));
102
- assert.equal(persistedCart?.iid, app.ctx.instance.id);
103
- const persistedItem = await app.scope('commerce-trade').model.cartItem.getById(itemId);
104
- assert.equal(String(persistedItem?.cartId), String(cart.id));
105
-
106
- const updated = await performAs(customerA.token, 'patch', `${actionPath}/items/:id`, {
107
- params: { id: itemId },
108
- body: { quantity: 3 } satisfies DtoCartUpdateItem,
109
- });
110
- assert.equal(updated.items[0].quantity, 3);
111
-
112
- assert.deepEqual(await performAs(customerB.token, 'get', actionPath), { items: [] });
113
- assert.deepEqual(
114
- await performAs(customerB.token, 'patch', `${actionPath}/items/:id`, {
115
- params: { id: itemId },
116
- body: { quantity: 1 },
117
- }),
118
- { items: [] },
119
- );
120
- assert.deepEqual(
121
- await performAs(customerB.token, 'delete', `${actionPath}/items/:id`, {
122
- params: { id: itemId },
123
- }),
124
- { items: [] },
125
- );
126
- assert.equal((await app.scope('commerce-trade').model.cartItem.getById(itemId))?.quantity, 3);
129
+ let sku: ISellableSkuFixture | undefined;
130
+ let customerA: Awaited<ReturnType<typeof registerAndSignin>> | undefined;
131
+ let customerB: Awaited<ReturnType<typeof registerAndSignin>> | undefined;
132
+ try {
133
+ sku = await createSellableSku(suffix);
134
+ customerA = await registerAndSignin(`cart-a-${suffix}`);
135
+ customerB = await registerAndSignin(`cart-b-${suffix}`);
127
136
 
128
- const merged = await performAs(customerA.token, 'post', `${actionPath}/items`, {
129
- body: cartItem(skuId, 2),
130
- });
131
- assert.equal(merged.items.length, 1);
132
- assert.equal(merged.items[0].quantity, 5);
137
+ const cart = await performAs(customerA.token, 'post', `${actionPath}/items`, {
138
+ body: { ...cartItem(sku.skuId, 2), userId: customerB.user.id, iid: 999999 } as any,
139
+ });
140
+ assert.equal(cart.items.length, 1);
141
+ assert.equal(cart.items[0].quantity, 2);
142
+ assert.equal(Object.hasOwn(cart, 'userId'), false);
143
+ assert.equal(Object.hasOwn(cart, 'iid'), false);
144
+ const itemId = cart.items[0].id;
133
145
 
134
- const cleared = await performAs(customerA.token, 'delete', `${actionPath}/items`);
135
- assert.deepEqual(cleared.items, []);
136
- });
137
- });
146
+ const persistedCart = await app.scope('commerce-trade').model.cart.getById(cart.id);
147
+ assert.equal(String(persistedCart?.userId), String(customerA.user.id));
148
+ assert.equal(persistedCart?.iid, app.ctx.instance.id);
149
+ const persistedItem = await app.scope('commerce-trade').model.cartItem.getById(itemId);
150
+ assert.equal(String(persistedItem?.cartId), String(cart.id));
138
151
 
139
- it('treats another instance cart as absent', async () => {
140
- let itemId!: number;
141
- let customerDefault!: Awaited<ReturnType<typeof registerAndSignin>>;
142
- await app.bean.executor.mockCtx(async () => {
143
- const suffix = createTestId();
144
- const skuId = await createSellableSku(suffix);
145
- customerDefault = await registerAndSignin(`cart-default-${suffix}`);
146
- const cart = await performAs(customerDefault.token, 'post', `${actionPath}/items`, {
147
- body: cartItem(skuId),
148
- });
149
- itemId = cart.items[0].id;
150
- });
152
+ const updated = await performAs(customerA.token, 'patch', `${actionPath}/items/:id`, {
153
+ params: { id: itemId },
154
+ body: { quantity: 3 } satisfies DtoCartUpdateItem,
155
+ });
156
+ assert.equal(updated.items[0].quantity, 3);
151
157
 
152
- await app.bean.executor.mockCtx(
153
- async () => {
154
- const customer = await registerAndSignin(`cart-share-${Date.now()}`);
155
- assert.deepEqual(await performAs(customer.token, 'get', actionPath), { items: [] });
158
+ assert.deepEqual(await performAs(customerB.token, 'get', actionPath), { items: [] });
156
159
  assert.deepEqual(
157
- await performAs(customer.token, 'patch', `${actionPath}/items/:id`, {
160
+ await performAs(customerB.token, 'patch', `${actionPath}/items/:id`, {
158
161
  params: { id: itemId },
159
- body: { quantity: 2 },
162
+ body: { quantity: 1 },
160
163
  }),
161
164
  { items: [] },
162
165
  );
163
166
  assert.deepEqual(
164
- await performAs(customer.token, 'delete', `${actionPath}/items/:id`, {
167
+ await performAs(customerB.token, 'delete', `${actionPath}/items/:id`, {
165
168
  params: { id: itemId },
166
169
  }),
167
170
  { items: [] },
168
171
  );
169
- },
170
- { instanceName: 'shareTest' as any },
171
- );
172
+ assert.equal(
173
+ (await app.scope('commerce-trade').model.cartItem.getById(itemId))?.quantity,
174
+ 3,
175
+ );
172
176
 
173
- await app.bean.executor.mockCtx(async () => {
174
- const cart = await performAs(customerDefault.token, 'get', actionPath);
175
- assert.equal(cart.items.length, 1);
176
- assert.equal(cart.items[0].id, itemId);
177
+ const merged = await performAs(customerA.token, 'post', `${actionPath}/items`, {
178
+ body: cartItem(sku.skuId, 2),
179
+ });
180
+ assert.equal(merged.items.length, 1);
181
+ assert.equal(merged.items[0].quantity, 5);
182
+
183
+ const cleared = await performAs(customerA.token, 'delete', `${actionPath}/items`);
184
+ assert.deepEqual(cleared.items, []);
185
+ } finally {
186
+ await app.bean.passport.signout();
187
+ await dropUser(customerB?.user.id as number | undefined);
188
+ await dropUser(customerA?.user.id as number | undefined);
189
+ await dropSellableSku(sku);
190
+ }
177
191
  });
178
192
  });
193
+
194
+ it('treats another instance cart as absent', async () => {
195
+ const suffix = createTestId();
196
+ let itemId!: number;
197
+ let customerDefault: Awaited<ReturnType<typeof registerAndSignin>> | undefined;
198
+ let customerShare: Awaited<ReturnType<typeof registerAndSignin>> | undefined;
199
+ let sku: ISellableSkuFixture | undefined;
200
+ try {
201
+ await app.bean.executor.mockCtx(async () => {
202
+ sku = await createSellableSku(suffix);
203
+ customerDefault = await registerAndSignin(`cart-default-${suffix}`);
204
+ const cart = await performAs(customerDefault.token, 'post', `${actionPath}/items`, {
205
+ body: cartItem(sku.skuId),
206
+ });
207
+ itemId = cart.items[0].id;
208
+ });
209
+
210
+ await app.bean.executor.mockCtx(
211
+ async () => {
212
+ try {
213
+ customerShare = await registerAndSignin(`cart-share-${suffix}`);
214
+ assert.deepEqual(await performAs(customerShare.token, 'get', actionPath), {
215
+ items: [],
216
+ });
217
+ assert.deepEqual(
218
+ await performAs(customerShare.token, 'patch', `${actionPath}/items/:id`, {
219
+ params: { id: itemId },
220
+ body: { quantity: 2 },
221
+ }),
222
+ { items: [] },
223
+ );
224
+ assert.deepEqual(
225
+ await performAs(customerShare.token, 'delete', `${actionPath}/items/:id`, {
226
+ params: { id: itemId },
227
+ }),
228
+ { items: [] },
229
+ );
230
+ } finally {
231
+ await app.bean.passport.signout();
232
+ }
233
+ },
234
+ { instanceName: 'shareTest' as any },
235
+ );
236
+
237
+ await app.bean.executor.mockCtx(async () => {
238
+ try {
239
+ const cart = await performAs(customerDefault!.token, 'get', actionPath);
240
+ assert.equal(cart.items.length, 1);
241
+ assert.equal(cart.items[0].id, itemId);
242
+ } finally {
243
+ await app.bean.passport.signout();
244
+ }
245
+ });
246
+ } finally {
247
+ await app.bean.executor.mockCtx(
248
+ async () => {
249
+ await dropUser(customerShare?.user.id as number | undefined);
250
+ },
251
+ { instanceName: 'shareTest' as any },
252
+ );
253
+ await app.bean.executor.mockCtx(async () => {
254
+ await dropUser(customerDefault?.user.id as number | undefined);
255
+ await dropSellableSku(sku);
256
+ });
257
+ }
258
+ });
179
259
  });