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,83 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IDecoratorEntityOptions } from 'vona-module-a-orm';
3
+
4
+ import { $makeMetadata, Api, v } from 'vona-module-a-openapiutils';
5
+ import { Entity, EntityBase } from 'vona-module-a-orm';
6
+ import { z } from 'zod';
7
+ import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
8
+
9
+ import { $locale } from '../.metadata/locales.ts';
10
+
11
+ export type TypeCouponGrantState = 'available' | 'reserved' | 'redeemed' | 'expired';
12
+
13
+ export interface IEntityOptionsCouponGrant extends IDecoratorEntityOptions {}
14
+
15
+ @Entity<IEntityOptionsCouponGrant>('commercePromotionCouponGrant', {
16
+ openapi: { title: $locale('CouponGrant') },
17
+ fields: {
18
+ id: $makeMetadata(ZovaRender.order(1, 'core')),
19
+ iid: $makeMetadata(ZovaRender.visible(false)),
20
+ userId: $makeMetadata(ZovaRender.visible(false)),
21
+ deleted: $makeMetadata(ZovaRender.visible(false)),
22
+ createdAt: $makeMetadata(
23
+ ZovaRender.order(-2, 'max'),
24
+ ZovaRender.field('basic-date:formFieldDate'),
25
+ ZovaRender.cell('basic-date:date'),
26
+ ),
27
+ updatedAt: $makeMetadata(
28
+ ZovaRender.order(-1, 'max'),
29
+ ZovaRender.field('basic-date:formFieldDate'),
30
+ ZovaRender.cell('basic-date:date'),
31
+ ),
32
+ },
33
+ })
34
+ export class EntityCouponGrant extends EntityBase {
35
+ @Api.field(v.title($locale('CouponTemplateId')), v.required(), v.tableIdentity())
36
+ templateId: TableIdentity;
37
+
38
+ @Api.field(v.title($locale('UserId')), v.required(), v.tableIdentity())
39
+ userId: TableIdentity;
40
+
41
+ @Api.field(v.title($locale('CouponCode')), v.required(), v.min(1), v.max(80))
42
+ couponCode: string;
43
+
44
+ @Api.field(
45
+ v.title($locale('CouponGrantState')),
46
+ v.required(),
47
+ z.enum(['available', 'reserved', 'redeemed', 'expired']),
48
+ )
49
+ state: TypeCouponGrantState;
50
+
51
+ @Api.field(v.title($locale('TemplateNameSnapshot')), v.required(), v.min(1), v.max(100))
52
+ templateNameSnapshot: string;
53
+
54
+ @Api.field(v.title($locale('Currency')), v.required(), z.literal('USD'))
55
+ currencySnapshot: 'USD';
56
+
57
+ @Api.field(v.title($locale('DiscountCents')), v.required(), z.number().int().positive())
58
+ discountCentsSnapshot: number;
59
+
60
+ @Api.field(v.title($locale('MinSpendCents')), v.required(), z.number().int().nonnegative())
61
+ minSpendCentsSnapshot: number;
62
+
63
+ @Api.field(v.title($locale('ValidFrom')), v.required())
64
+ validFromSnapshot: Date;
65
+
66
+ @Api.field(v.title($locale('ValidUntil')), v.required())
67
+ validUntilSnapshot: Date;
68
+
69
+ @Api.field(v.title($locale('ReservationOrderId')), v.optional(), v.tableIdentity())
70
+ reservationOrderId?: TableIdentity;
71
+
72
+ @Api.field(v.title($locale('ReservationCorrelationId')), v.optional(), v.max(100))
73
+ reservationCorrelationId?: string;
74
+
75
+ @Api.field(v.title($locale('ReservedAt')), v.optional())
76
+ reservedAt?: Date;
77
+
78
+ @Api.field(v.title($locale('RedeemedOrderId')), v.optional(), v.tableIdentity())
79
+ redeemedOrderId?: TableIdentity;
80
+
81
+ @Api.field(v.title($locale('RedeemedAt')), v.optional())
82
+ redeemedAt?: Date;
83
+ }
@@ -0,0 +1,82 @@
1
+ import type { IDecoratorEntityOptions } from 'vona-module-a-orm';
2
+
3
+ import { $makeMetadata, Api, v } from 'vona-module-a-openapiutils';
4
+ import { Entity, EntityBase } from 'vona-module-a-orm';
5
+ import { z } from 'zod';
6
+ import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
7
+
8
+ import { $locale } from '../.metadata/locales.ts';
9
+
10
+ export type TypeCouponTemplateState = 'draft' | 'active' | 'disabled';
11
+
12
+ export interface IEntityOptionsCouponTemplate extends IDecoratorEntityOptions {}
13
+
14
+ @Entity<IEntityOptionsCouponTemplate>('commercePromotionCouponTemplate', {
15
+ openapi: { title: $locale('CouponTemplate') },
16
+ fields: {
17
+ id: $makeMetadata(ZovaRender.order(1, 'core')),
18
+ iid: $makeMetadata(ZovaRender.visible(false)),
19
+ deleted: $makeMetadata(ZovaRender.visible(false)),
20
+ createdAt: $makeMetadata(
21
+ ZovaRender.order(-2, 'max'),
22
+ ZovaRender.field('basic-date:formFieldDate'),
23
+ ZovaRender.cell('basic-date:date'),
24
+ ),
25
+ updatedAt: $makeMetadata(
26
+ ZovaRender.order(-1, 'max'),
27
+ ZovaRender.field('basic-date:formFieldDate'),
28
+ ZovaRender.cell('basic-date:date'),
29
+ ),
30
+ },
31
+ })
32
+ export class EntityCouponTemplate extends EntityBase {
33
+ @Api.field(
34
+ v.title($locale('Name')),
35
+ v.required(),
36
+ v.min(2),
37
+ v.max(100),
38
+ ZovaRender.order(1),
39
+ ZovaRender.cell('basic-table:actionView'),
40
+ )
41
+ name: string;
42
+
43
+ @Api.field(
44
+ v.title($locale('CouponTemplateState')),
45
+ v.required(),
46
+ z.enum(['draft', 'active', 'disabled']),
47
+ )
48
+ state: TypeCouponTemplateState;
49
+
50
+ @Api.field(v.title($locale('Currency')), v.required(), z.literal('USD'))
51
+ currency: 'USD';
52
+
53
+ @Api.field(v.title($locale('DiscountCents')), v.required(), z.number().int().positive())
54
+ discountCents: number;
55
+
56
+ @Api.field(v.title($locale('MinSpendCents')), v.required(), z.number().int().nonnegative())
57
+ minSpendCents: number;
58
+
59
+ @Api.field(v.title($locale('ValidFrom')), v.required())
60
+ validFrom: Date;
61
+
62
+ @Api.field(v.title($locale('ValidUntil')), v.required())
63
+ validUntil: Date;
64
+
65
+ @Api.field(v.title($locale('TotalIssueLimit')), v.optional(), z.number().int().positive())
66
+ totalIssueLimit?: number;
67
+
68
+ @Api.field(v.title($locale('TotalUsageLimit')), v.optional(), z.number().int().positive())
69
+ totalUsageLimit?: number;
70
+
71
+ @Api.field(v.title($locale('PerCustomerIssueLimit')), v.optional(), z.number().int().positive())
72
+ perCustomerIssueLimit?: number;
73
+
74
+ @Api.field(v.title($locale('IssuedCount')), v.required(), z.number().int().nonnegative())
75
+ issuedCount: number;
76
+
77
+ @Api.field(v.title($locale('RedeemedCount')), v.required(), z.number().int().nonnegative())
78
+ redeemedCount: number;
79
+
80
+ @Api.field(v.title($locale('Description')), v.optional(), v.max(255))
81
+ description?: string;
82
+ }
@@ -1 +1,2 @@
1
+ export * from './.metadata/locales.ts';
1
2
  export * from './.metadata/index.ts';
@@ -0,0 +1,10 @@
1
+ import type { IDecoratorModelOptions } from 'vona-module-a-orm';
2
+
3
+ import { BeanModelBase, Model } from 'vona-module-a-orm';
4
+
5
+ import { EntityCouponAudit } from '../entity/couponAudit.tsx';
6
+
7
+ export interface IModelOptionsCouponAudit extends IDecoratorModelOptions<EntityCouponAudit> {}
8
+
9
+ @Model<IModelOptionsCouponAudit>({ entity: EntityCouponAudit })
10
+ export class ModelCouponAudit extends BeanModelBase<EntityCouponAudit> {}
@@ -0,0 +1,10 @@
1
+ import type { IDecoratorModelOptions } from 'vona-module-a-orm';
2
+
3
+ import { BeanModelBase, Model } from 'vona-module-a-orm';
4
+
5
+ import { EntityCouponGrant } from '../entity/couponGrant.tsx';
6
+
7
+ export interface IModelOptionsCouponGrant extends IDecoratorModelOptions<EntityCouponGrant> {}
8
+
9
+ @Model<IModelOptionsCouponGrant>({ entity: EntityCouponGrant })
10
+ export class ModelCouponGrant extends BeanModelBase<EntityCouponGrant> {}
@@ -0,0 +1,10 @@
1
+ import type { IDecoratorModelOptions } from 'vona-module-a-orm';
2
+
3
+ import { BeanModelBase, Model } from 'vona-module-a-orm';
4
+
5
+ import { EntityCouponTemplate } from '../entity/couponTemplate.tsx';
6
+
7
+ export interface IModelOptionsCouponTemplate extends IDecoratorModelOptions<EntityCouponTemplate> {}
8
+
9
+ @Model<IModelOptionsCouponTemplate>({ entity: EntityCouponTemplate })
10
+ export class ModelCouponTemplate extends BeanModelBase<EntityCouponTemplate> {}
@@ -0,0 +1,397 @@
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 { EntityCouponGrant } from '../entity/couponGrant.tsx';
8
+ import type { EntityCouponTemplate } from '../entity/couponTemplate.tsx';
9
+
10
+ export interface ICouponIssueCommand {
11
+ templateId: TableIdentity;
12
+ userId: TableIdentity;
13
+ correlationId: string;
14
+ reason: string;
15
+ }
16
+
17
+ export interface ICouponReserveCommand {
18
+ couponGrantId: TableIdentity;
19
+ userId: TableIdentity;
20
+ orderId: TableIdentity;
21
+ eligibleSubtotalCents: number;
22
+ currency: 'USD';
23
+ correlationId: string;
24
+ reason: string;
25
+ }
26
+
27
+ export interface ICouponTransitionCommand {
28
+ couponGrantId: TableIdentity;
29
+ orderId: TableIdentity;
30
+ correlationId: string;
31
+ reason: string;
32
+ }
33
+
34
+ export interface ICouponReservationResult {
35
+ couponGrant: EntityCouponGrant;
36
+ discountCents: number;
37
+ }
38
+
39
+ const serializationRetryOptions = {
40
+ retries: 1,
41
+ factor: 1,
42
+ minTimeout: 0,
43
+ maxTimeout: 0,
44
+ randomize: false,
45
+ errorCodes: ['40001'],
46
+ ownerOnly: true,
47
+ };
48
+
49
+ @Service()
50
+ export class ServiceCoupon extends BeanBase {
51
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
52
+ @Core.retryable(serializationRetryOptions)
53
+ async issue(command: ICouponIssueCommand): Promise<EntityCouponGrant> {
54
+ this._assertCommand(command);
55
+ const template = await this.scope.model.couponTemplate.getByIdForUpdate(command.templateId);
56
+ if (!template) this.app.throw(404, 'coupon template not found');
57
+ this._assertTemplateIssuable(template);
58
+ const existing = await this.scope.model.couponGrant.get({
59
+ couponCode: command.correlationId,
60
+ });
61
+ if (existing) {
62
+ if (
63
+ String(existing.templateId) !== String(command.templateId) ||
64
+ String(existing.userId) !== String(command.userId)
65
+ ) {
66
+ this.app.throw(409, 'coupon issuance correlation conflicts with an existing grant');
67
+ }
68
+ return existing;
69
+ }
70
+ if (template.totalIssueLimit && template.issuedCount >= template.totalIssueLimit) {
71
+ this.app.throw(409, 'coupon total issuance limit reached');
72
+ }
73
+ const perCustomerCount = Number(
74
+ (await this.scope.model.couponGrant.count({
75
+ where: { templateId: template.id, userId: command.userId },
76
+ })) ?? 0,
77
+ );
78
+ if (template.perCustomerIssueLimit && perCustomerCount >= template.perCustomerIssueLimit) {
79
+ this.app.throw(409, 'coupon per-customer issuance limit reached');
80
+ }
81
+ const grant = await this.scope.model.couponGrant.insert({
82
+ templateId: template.id,
83
+ userId: command.userId,
84
+ couponCode: command.correlationId,
85
+ state: 'available',
86
+ templateNameSnapshot: template.name,
87
+ currencySnapshot: template.currency,
88
+ discountCentsSnapshot: template.discountCents,
89
+ minSpendCentsSnapshot: template.minSpendCents,
90
+ validFromSnapshot: template.validFrom,
91
+ validUntilSnapshot: template.validUntil,
92
+ });
93
+ await this.scope.model.couponTemplate.updateById(template.id, {
94
+ issuedCount: template.issuedCount + 1,
95
+ });
96
+ await this._appendAudit({
97
+ grant,
98
+ operation: 'issue',
99
+ toState: 'available',
100
+ correlationId: command.correlationId,
101
+ reason: command.reason,
102
+ });
103
+ return grant;
104
+ }
105
+
106
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
107
+ @Core.retryable(serializationRetryOptions)
108
+ async reserve(command: ICouponReserveCommand): Promise<ICouponReservationResult> {
109
+ this._assertCommand(command);
110
+ if (!Number.isSafeInteger(command.eligibleSubtotalCents) || command.eligibleSubtotalCents < 0) {
111
+ this.app.throw(400, 'coupon eligible subtotal must be a non-negative integer');
112
+ }
113
+ const grant = await this.scope.model.couponGrant.getByIdForUpdate(command.couponGrantId);
114
+ if (!grant || String(grant.userId) !== String(command.userId)) {
115
+ this.app.throw(404, 'coupon grant not found');
116
+ }
117
+ if (grant.state === 'reserved') {
118
+ if (
119
+ String(grant.reservationOrderId) === String(command.orderId) &&
120
+ grant.reservationCorrelationId === command.correlationId
121
+ ) {
122
+ return {
123
+ couponGrant: grant,
124
+ discountCents: Math.min(grant.discountCentsSnapshot, command.eligibleSubtotalCents),
125
+ };
126
+ }
127
+ this.app.throw(409, 'coupon grant is already reserved');
128
+ }
129
+ if (grant.state !== 'available') {
130
+ this.app.throw(409, `cannot reserve a ${grant.state} coupon grant`);
131
+ }
132
+ const template = await this.scope.model.couponTemplate.getByIdForUpdate(grant.templateId);
133
+ if (!template) this.app.throw(404, 'coupon template not found');
134
+ this._assertTemplateReservable(template, grant);
135
+ const reservedCount = Number(
136
+ (await this.scope.model.couponGrant.count({
137
+ where: { templateId: template.id, state: 'reserved' },
138
+ })) ?? 0,
139
+ );
140
+ if (
141
+ template.totalUsageLimit &&
142
+ template.redeemedCount + reservedCount >= template.totalUsageLimit
143
+ ) {
144
+ this.app.throw(409, 'coupon total usage limit reached');
145
+ }
146
+ if (grant.currencySnapshot !== command.currency) {
147
+ this.app.throw(409, 'coupon currency does not match the order');
148
+ }
149
+ if (command.eligibleSubtotalCents < grant.minSpendCentsSnapshot) {
150
+ this.app.throw(409, 'coupon minimum spend is not met');
151
+ }
152
+ const existingOrderGrant = await this.scope.model.couponGrant.getForUpdate({
153
+ reservationOrderId: command.orderId,
154
+ });
155
+ if (existingOrderGrant && String(existingOrderGrant.id) !== String(grant.id)) {
156
+ this.app.throw(409, 'an order can reserve only one coupon');
157
+ }
158
+ const reservedAt = new Date();
159
+ await this.scope.model.couponGrant.updateById(grant.id, {
160
+ state: 'reserved',
161
+ reservationOrderId: command.orderId,
162
+ reservationCorrelationId: command.correlationId,
163
+ reservedAt,
164
+ });
165
+ const reservedGrant = {
166
+ ...grant,
167
+ state: 'reserved' as const,
168
+ reservationOrderId: command.orderId,
169
+ reservationCorrelationId: command.correlationId,
170
+ reservedAt,
171
+ };
172
+ await this._appendAudit({
173
+ grant: reservedGrant,
174
+ operation: 'reserve',
175
+ fromState: 'available',
176
+ toState: 'reserved',
177
+ orderId: command.orderId,
178
+ correlationId: command.correlationId,
179
+ reason: command.reason,
180
+ });
181
+ return {
182
+ couponGrant: reservedGrant,
183
+ discountCents: Math.min(grant.discountCentsSnapshot, command.eligibleSubtotalCents),
184
+ };
185
+ }
186
+
187
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
188
+ @Core.retryable(serializationRetryOptions)
189
+ async release(command: ICouponTransitionCommand): Promise<EntityCouponGrant> {
190
+ return await this._transition(command, 'available');
191
+ }
192
+
193
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
194
+ @Core.retryable(serializationRetryOptions)
195
+ async redeem(command: ICouponTransitionCommand): Promise<EntityCouponGrant> {
196
+ return await this._transition(command, 'redeemed');
197
+ }
198
+
199
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
200
+ @Core.retryable(serializationRetryOptions)
201
+ async expireAvailable(now = new Date()): Promise<number> {
202
+ const grants = await this.scope.model.couponGrant.select({
203
+ where: { state: 'available' },
204
+ });
205
+ let expired = 0;
206
+ for (const grant of grants) {
207
+ if (grant.validUntilSnapshot > now) continue;
208
+ const lockedGrant = await this.scope.model.couponGrant.getByIdForUpdate(grant.id);
209
+ if (
210
+ !lockedGrant ||
211
+ lockedGrant.state !== 'available' ||
212
+ lockedGrant.validUntilSnapshot > now
213
+ ) {
214
+ continue;
215
+ }
216
+ await this.scope.model.couponGrant.updateById(lockedGrant.id, { state: 'expired' });
217
+ await this._appendAudit({
218
+ grant: lockedGrant,
219
+ operation: 'expire',
220
+ fromState: 'available',
221
+ toState: 'expired',
222
+ correlationId: `coupon-expiry:${lockedGrant.id}:${lockedGrant.validUntilSnapshot.toISOString()}`,
223
+ reason: 'coupon validity expired',
224
+ });
225
+ expired++;
226
+ }
227
+ return expired;
228
+ }
229
+
230
+ async mine(): Promise<EntityCouponGrant[]> {
231
+ const now = new Date();
232
+ return await this.scope.model.couponGrant
233
+ .select({
234
+ where: { userId: this._getCurrentUserId(), state: 'available' },
235
+ orders: [['validUntilSnapshot', 'asc']],
236
+ })
237
+ .then(grants =>
238
+ grants.filter(grant => grant.validFromSnapshot <= now && grant.validUntilSnapshot > now),
239
+ );
240
+ }
241
+
242
+ private async _transition(
243
+ command: ICouponTransitionCommand,
244
+ targetState: 'available' | 'redeemed',
245
+ ): Promise<EntityCouponGrant> {
246
+ this._assertCommand(command);
247
+ const grant = await this.scope.model.couponGrant.getByIdForUpdate(command.couponGrantId);
248
+ if (!grant) this.app.throw(404, 'coupon grant not found');
249
+ const currentOrderId =
250
+ targetState === 'redeemed'
251
+ ? (grant.redeemedOrderId ?? grant.reservationOrderId)
252
+ : grant.reservationOrderId;
253
+ if (String(currentOrderId) !== String(command.orderId)) {
254
+ if (targetState === 'available') {
255
+ const releasedAudit = await this.scope.model.couponAudit.get({
256
+ couponGrantId: grant.id,
257
+ operation: 'release',
258
+ orderId: command.orderId,
259
+ correlationId: command.correlationId,
260
+ });
261
+ if (releasedAudit && (grant.state === 'available' || grant.state === 'expired'))
262
+ return grant;
263
+ }
264
+ this.app.throw(404, 'coupon grant reservation not found');
265
+ }
266
+ if (grant.state === targetState) return grant;
267
+ if (grant.state !== 'reserved') {
268
+ this.app.throw(
269
+ 409,
270
+ `cannot ${targetState === 'redeemed' ? 'redeem' : 'release'} a ${grant.state} coupon grant`,
271
+ );
272
+ }
273
+ if (targetState === 'redeemed') {
274
+ const template = await this.scope.model.couponTemplate.getByIdForUpdate(grant.templateId);
275
+ if (!template) this.app.throw(404, 'coupon template not found');
276
+ if (template.totalUsageLimit && template.redeemedCount >= template.totalUsageLimit) {
277
+ this.app.throw(409, 'coupon total usage limit reached');
278
+ }
279
+ const redeemedAt = new Date();
280
+ await this.scope.model.couponGrant.updateById(grant.id, {
281
+ state: 'redeemed',
282
+ redeemedOrderId: command.orderId,
283
+ redeemedAt,
284
+ });
285
+ await this.scope.model.couponTemplate.updateById(template.id, {
286
+ redeemedCount: template.redeemedCount + 1,
287
+ });
288
+ const redeemedGrant = {
289
+ ...grant,
290
+ state: 'redeemed' as const,
291
+ redeemedOrderId: command.orderId,
292
+ redeemedAt,
293
+ };
294
+ await this._appendAudit({
295
+ grant: redeemedGrant,
296
+ operation: 'redeem',
297
+ fromState: 'reserved',
298
+ toState: 'redeemed',
299
+ orderId: command.orderId,
300
+ correlationId: command.correlationId,
301
+ reason: command.reason,
302
+ });
303
+ return redeemedGrant;
304
+ }
305
+ const now = new Date();
306
+ const expired = grant.validUntilSnapshot <= now;
307
+ const releasedGrant = {
308
+ ...grant,
309
+ state: (expired ? 'expired' : 'available') as EntityCouponGrant['state'],
310
+ reservationOrderId: undefined,
311
+ reservationCorrelationId: undefined,
312
+ reservedAt: undefined,
313
+ };
314
+ await this.scope.model.couponGrant.updateById(grant.id, {
315
+ state: releasedGrant.state,
316
+ reservationOrderId: undefined,
317
+ reservationCorrelationId: undefined,
318
+ reservedAt: undefined,
319
+ });
320
+ await this._appendAudit({
321
+ grant: releasedGrant,
322
+ operation: 'release',
323
+ fromState: 'reserved',
324
+ toState: releasedGrant.state,
325
+ orderId: command.orderId,
326
+ correlationId: command.correlationId,
327
+ reason: command.reason,
328
+ });
329
+ return releasedGrant;
330
+ }
331
+
332
+ private async _appendAudit({
333
+ grant,
334
+ operation,
335
+ fromState,
336
+ toState,
337
+ orderId,
338
+ correlationId,
339
+ reason,
340
+ }: {
341
+ grant: EntityCouponGrant;
342
+ operation: 'issue' | 'reserve' | 'release' | 'redeem' | 'expire';
343
+ fromState?: EntityCouponGrant['state'];
344
+ toState: EntityCouponGrant['state'];
345
+ orderId?: TableIdentity;
346
+ correlationId: string;
347
+ reason: string;
348
+ }) {
349
+ await this.scope.model.couponAudit.insert({
350
+ couponGrantId: grant.id,
351
+ templateId: grant.templateId,
352
+ userId: grant.userId,
353
+ orderId,
354
+ actorId: this.bean.passport.currentUser?.anonymous
355
+ ? undefined
356
+ : this.bean.passport.currentUser?.id,
357
+ operation,
358
+ fromState,
359
+ toState,
360
+ correlationId,
361
+ reason,
362
+ });
363
+ }
364
+
365
+ private _assertTemplateIssuable(template: EntityCouponTemplate | undefined) {
366
+ if (!template) this.app.throw(404, 'coupon template not found');
367
+ const now = new Date();
368
+ if (template.state !== 'active' || template.validFrom > now || template.validUntil <= now) {
369
+ this.app.throw(409, 'coupon template is not currently issuable');
370
+ }
371
+ }
372
+
373
+ private _assertTemplateReservable(
374
+ template: EntityCouponTemplate | undefined,
375
+ grant: EntityCouponGrant,
376
+ ) {
377
+ if (!template) this.app.throw(404, 'coupon template not found');
378
+ const now = new Date();
379
+ if (
380
+ template.state !== 'active' ||
381
+ grant.validFromSnapshot > now ||
382
+ grant.validUntilSnapshot <= now
383
+ ) {
384
+ this.app.throw(409, 'coupon grant is not currently reservable');
385
+ }
386
+ }
387
+
388
+ private _getCurrentUserId(): TableIdentity {
389
+ return this.bean.passport.currentUser!.id;
390
+ }
391
+
392
+ private _assertCommand(command: { correlationId: string; reason: string }) {
393
+ if (!command.correlationId?.trim()) this.app.throw(400, 'coupon correlationId is required');
394
+ if (command.correlationId.length > 100) this.app.throw(400, 'coupon correlationId is too long');
395
+ if (!command.reason?.trim()) this.app.throw(400, 'coupon reason is required');
396
+ }
397
+ }
@@ -0,0 +1,52 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IQueryParams } from 'vona-module-a-orm';
3
+
4
+ import { BeanBase } from 'vona';
5
+ import { Service } from 'vona-module-a-bean';
6
+
7
+ import type { DtoCouponTemplateCreate } from '../dto/couponTemplateCreate.tsx';
8
+ import type { DtoCouponTemplateSelectRes } from '../dto/couponTemplateSelectRes.tsx';
9
+ import type { DtoCouponTemplateUpdate } from '../dto/couponTemplateUpdate.tsx';
10
+ import type { DtoCouponTemplateView } from '../dto/couponTemplateView.tsx';
11
+ import type { EntityCouponTemplate } from '../entity/couponTemplate.tsx';
12
+ import type { ModelCouponTemplate } from '../model/couponTemplate.ts';
13
+
14
+ @Service()
15
+ export class ServiceCouponTemplate extends BeanBase {
16
+ async create(command: DtoCouponTemplateCreate): Promise<EntityCouponTemplate> {
17
+ this._assertValidityWindow(command.validFrom, command.validUntil);
18
+ return await this.scope.model.couponTemplate.insert({
19
+ ...command,
20
+ issuedCount: 0,
21
+ redeemedCount: 0,
22
+ });
23
+ }
24
+
25
+ async select(params?: IQueryParams<ModelCouponTemplate>): Promise<DtoCouponTemplateSelectRes> {
26
+ return await this.scope.model.couponTemplate.selectAndCount(params);
27
+ }
28
+
29
+ async view(id: TableIdentity): Promise<DtoCouponTemplateView | undefined> {
30
+ return await this.scope.model.couponTemplate.getById(id);
31
+ }
32
+
33
+ async update(id: TableIdentity, couponTemplate: DtoCouponTemplateUpdate) {
34
+ const template = await this.scope.model.couponTemplate.getById(id);
35
+ if (!template) return;
36
+ const issued = await this.scope.model.couponGrant.count({ where: { templateId: template.id } });
37
+ if (Number(issued ?? 0) > 0) {
38
+ this.app.throw(409, 'cannot edit an issued coupon template');
39
+ }
40
+ return await this.scope.model.couponTemplate.updateById(id, couponTemplate);
41
+ }
42
+
43
+ async delete(id: TableIdentity) {
44
+ const issued = await this.scope.model.couponGrant.count({ where: { templateId: id } });
45
+ if (Number(issued ?? 0) > 0) this.app.throw(409, 'cannot delete an issued coupon template');
46
+ return await this.scope.model.couponTemplate.deleteById(id);
47
+ }
48
+
49
+ private _assertValidityWindow(validFrom: Date, validUntil: Date) {
50
+ if (validFrom >= validUntil) this.app.throw(400, 'coupon validFrom must precede validUntil');
51
+ }
52
+ }