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,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
|
+
}
|
|
@@ -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
|
+
}
|