@endora-commerce/mod-promotions 0.100.0
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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/admin/api/promotions-client.d.ts +98 -0
- package/dist/admin/api/promotions-client.d.ts.map +1 -0
- package/dist/admin/api/promotions-client.js +40 -0
- package/dist/admin/api/promotions-client.js.map +1 -0
- package/dist/admin/components/CouponGeneratorForm.d.ts +10 -0
- package/dist/admin/components/CouponGeneratorForm.d.ts.map +1 -0
- package/dist/admin/components/CouponGeneratorForm.js +56 -0
- package/dist/admin/components/CouponGeneratorForm.js.map +1 -0
- package/dist/admin/index.d.ts +43 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +69 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/PromotionEditPage.d.ts +9 -0
- package/dist/admin/pages/PromotionEditPage.d.ts.map +1 -0
- package/dist/admin/pages/PromotionEditPage.js +333 -0
- package/dist/admin/pages/PromotionEditPage.js.map +1 -0
- package/dist/admin/pages/PromotionRulesPage.d.ts +14 -0
- package/dist/admin/pages/PromotionRulesPage.d.ts.map +1 -0
- package/dist/admin/pages/PromotionRulesPage.js +86 -0
- package/dist/admin/pages/PromotionRulesPage.js.map +1 -0
- package/dist/admin/pages/PromotionStatsPage.d.ts +13 -0
- package/dist/admin/pages/PromotionStatsPage.d.ts.map +1 -0
- package/dist/admin/pages/PromotionStatsPage.js +44 -0
- package/dist/admin/pages/PromotionStatsPage.js.map +1 -0
- package/dist/admin/pages/PromotionsPage.d.ts +9 -0
- package/dist/admin/pages/PromotionsPage.d.ts.map +1 -0
- package/dist/admin/pages/PromotionsPage.js +79 -0
- package/dist/admin/pages/PromotionsPage.js.map +1 -0
- package/dist/backend/actions/amount-off-cart.d.ts +4 -0
- package/dist/backend/actions/amount-off-cart.d.ts.map +1 -0
- package/dist/backend/actions/amount-off-cart.js +16 -0
- package/dist/backend/actions/amount-off-cart.js.map +1 -0
- package/dist/backend/actions/buy-x-get-y-free.d.ts +8 -0
- package/dist/backend/actions/buy-x-get-y-free.d.ts.map +1 -0
- package/dist/backend/actions/buy-x-get-y-free.js +20 -0
- package/dist/backend/actions/buy-x-get-y-free.js.map +1 -0
- package/dist/backend/actions/buy-x-units-amount-off.d.ts +4 -0
- package/dist/backend/actions/buy-x-units-amount-off.d.ts.map +1 -0
- package/dist/backend/actions/buy-x-units-amount-off.js +19 -0
- package/dist/backend/actions/buy-x-units-amount-off.js.map +1 -0
- package/dist/backend/actions/buy-x-units-percent-off.d.ts +4 -0
- package/dist/backend/actions/buy-x-units-percent-off.d.ts.map +1 -0
- package/dist/backend/actions/buy-x-units-percent-off.js +15 -0
- package/dist/backend/actions/buy-x-units-percent-off.js.map +1 -0
- package/dist/backend/actions/buy-x-units-y-free.d.ts +7 -0
- package/dist/backend/actions/buy-x-units-y-free.d.ts.map +1 -0
- package/dist/backend/actions/buy-x-units-y-free.js +21 -0
- package/dist/backend/actions/buy-x-units-y-free.js.map +1 -0
- package/dist/backend/actions/every-nth-product-percent-off.d.ts +7 -0
- package/dist/backend/actions/every-nth-product-percent-off.d.ts.map +1 -0
- package/dist/backend/actions/every-nth-product-percent-off.js +17 -0
- package/dist/backend/actions/every-nth-product-percent-off.js.map +1 -0
- package/dist/backend/actions/free-delivery.d.ts +4 -0
- package/dist/backend/actions/free-delivery.d.ts.map +1 -0
- package/dist/backend/actions/free-delivery.js +11 -0
- package/dist/backend/actions/free-delivery.js.map +1 -0
- package/dist/backend/actions/percentage-off-cart.d.ts +4 -0
- package/dist/backend/actions/percentage-off-cart.d.ts.map +1 -0
- package/dist/backend/actions/percentage-off-cart.js +11 -0
- package/dist/backend/actions/percentage-off-cart.js.map +1 -0
- package/dist/backend/actions/spend-x-amount-off.d.ts +4 -0
- package/dist/backend/actions/spend-x-amount-off.d.ts.map +1 -0
- package/dist/backend/actions/spend-x-amount-off.js +17 -0
- package/dist/backend/actions/spend-x-amount-off.js.map +1 -0
- package/dist/backend/actions/spend-x-percent-off.d.ts +4 -0
- package/dist/backend/actions/spend-x-percent-off.d.ts.map +1 -0
- package/dist/backend/actions/spend-x-percent-off.js +18 -0
- package/dist/backend/actions/spend-x-percent-off.js.map +1 -0
- package/dist/backend/actions/types.d.ts +37 -0
- package/dist/backend/actions/types.d.ts.map +1 -0
- package/dist/backend/actions/types.js +2 -0
- package/dist/backend/actions/types.js.map +1 -0
- package/dist/backend/actions/util.d.ts +13 -0
- package/dist/backend/actions/util.d.ts.map +1 -0
- package/dist/backend/actions/util.js +18 -0
- package/dist/backend/actions/util.js.map +1 -0
- package/dist/backend/entities/coupon-batch.entity.d.ts +21 -0
- package/dist/backend/entities/coupon-batch.entity.d.ts.map +1 -0
- package/dist/backend/entities/coupon-batch.entity.js +78 -0
- package/dist/backend/entities/coupon-batch.entity.js.map +1 -0
- package/dist/backend/entities/promotion-coupon.entity.d.ts +17 -0
- package/dist/backend/entities/promotion-coupon.entity.d.ts.map +1 -0
- package/dist/backend/entities/promotion-coupon.entity.js +64 -0
- package/dist/backend/entities/promotion-coupon.entity.js.map +1 -0
- package/dist/backend/entities/promotion-rule.entity.d.ts +16 -0
- package/dist/backend/entities/promotion-rule.entity.d.ts.map +1 -0
- package/dist/backend/entities/promotion-rule.entity.js +57 -0
- package/dist/backend/entities/promotion-rule.entity.js.map +1 -0
- package/dist/backend/entities/promotion-usage-counter.entity.d.ts +15 -0
- package/dist/backend/entities/promotion-usage-counter.entity.d.ts.map +1 -0
- package/dist/backend/entities/promotion-usage-counter.entity.js +48 -0
- package/dist/backend/entities/promotion-usage-counter.entity.js.map +1 -0
- package/dist/backend/entities/promotion-usage.entity.d.ts +21 -0
- package/dist/backend/entities/promotion-usage.entity.d.ts.map +1 -0
- package/dist/backend/entities/promotion-usage.entity.js +89 -0
- package/dist/backend/entities/promotion-usage.entity.js.map +1 -0
- package/dist/backend/entities/promotion.entity.d.ts +63 -0
- package/dist/backend/entities/promotion.entity.d.ts.map +1 -0
- package/dist/backend/entities/promotion.entity.js +194 -0
- package/dist/backend/entities/promotion.entity.js.map +1 -0
- package/dist/backend/index.d.ts +124 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +182 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.d.ts +88 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +256 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/services/coupon-service.d.ts +40 -0
- package/dist/backend/services/coupon-service.d.ts.map +1 -0
- package/dist/backend/services/coupon-service.js +178 -0
- package/dist/backend/services/coupon-service.js.map +1 -0
- package/dist/backend/services/promotion-action-registry.d.ts +20 -0
- package/dist/backend/services/promotion-action-registry.d.ts.map +1 -0
- package/dist/backend/services/promotion-action-registry.js +57 -0
- package/dist/backend/services/promotion-action-registry.js.map +1 -0
- package/dist/backend/services/promotion-code-port.d.ts +24 -0
- package/dist/backend/services/promotion-code-port.d.ts.map +1 -0
- package/dist/backend/services/promotion-code-port.js +53 -0
- package/dist/backend/services/promotion-code-port.js.map +1 -0
- package/dist/backend/services/promotion-currency-reference.d.ts +13 -0
- package/dist/backend/services/promotion-currency-reference.d.ts.map +1 -0
- package/dist/backend/services/promotion-currency-reference.js +25 -0
- package/dist/backend/services/promotion-currency-reference.js.map +1 -0
- package/dist/backend/services/promotion-rule-evaluator.d.ts +32 -0
- package/dist/backend/services/promotion-rule-evaluator.d.ts.map +1 -0
- package/dist/backend/services/promotion-rule-evaluator.js +131 -0
- package/dist/backend/services/promotion-rule-evaluator.js.map +1 -0
- package/dist/backend/services/promotion-rule-service.d.ts +62 -0
- package/dist/backend/services/promotion-rule-service.d.ts.map +1 -0
- package/dist/backend/services/promotion-rule-service.js +95 -0
- package/dist/backend/services/promotion-rule-service.js.map +1 -0
- package/dist/backend/services/promotion-rule-store.d.ts +33 -0
- package/dist/backend/services/promotion-rule-store.d.ts.map +1 -0
- package/dist/backend/services/promotion-rule-store.js +88 -0
- package/dist/backend/services/promotion-rule-store.js.map +1 -0
- package/dist/backend/services/promotion-service.d.ts +312 -0
- package/dist/backend/services/promotion-service.d.ts.map +1 -0
- package/dist/backend/services/promotion-service.js +762 -0
- package/dist/backend/services/promotion-service.js.map +1 -0
- package/dist/backend/services/promotion-stats-service.d.ts +15 -0
- package/dist/backend/services/promotion-stats-service.d.ts.map +1 -0
- package/dist/backend/services/promotion-stats-service.js +66 -0
- package/dist/backend/services/promotion-stats-service.js.map +1 -0
- package/dist/backend/services/promotion-usage-service.d.ts +46 -0
- package/dist/backend/services/promotion-usage-service.d.ts.map +1 -0
- package/dist/backend/services/promotion-usage-service.js +168 -0
- package/dist/backend/services/promotion-usage-service.js.map +1 -0
- package/dist/manifest.d.ts +172 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +140 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260505T074605_promotions_criteria.d.ts +19 -0
- package/dist/migrations/20260505T074605_promotions_criteria.d.ts.map +1 -0
- package/dist/migrations/20260505T074605_promotions_criteria.js +23 -0
- package/dist/migrations/20260505T074605_promotions_criteria.js.map +1 -0
- package/dist/migrations/20260618T100727_promotions_engine.d.ts +19 -0
- package/dist/migrations/20260618T100727_promotions_engine.d.ts.map +1 -0
- package/dist/migrations/20260618T100727_promotions_engine.js +148 -0
- package/dist/migrations/20260618T100727_promotions_engine.js.map +1 -0
- package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.d.ts +42 -0
- package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.d.ts.map +1 -0
- package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.js +76 -0
- package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.js.map +1 -0
- package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.d.ts +27 -0
- package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.d.ts.map +1 -0
- package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.js +44 -0
- package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.js.map +1 -0
- package/dist/migrations/index.d.ts +39 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +44 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/ports/index.d.ts +106 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +2 -0
- package/dist/ports/index.js.map +1 -0
- package/docs/promotions.md +96 -0
- package/i18n/en.json +34 -0
- package/i18n/pl.json +34 -0
- package/package.json +101 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Promotion } from '../entities/promotion.entity.js';
|
|
2
|
+
import { PromotionCoupon } from '../entities/promotion-coupon.entity.js';
|
|
3
|
+
/**
|
|
4
|
+
* The coupon-resolution port `promotions` publishes (feature 075, Phase P).
|
|
5
|
+
*
|
|
6
|
+
* `carts` reaches both entity classes for exactly one question — "is this code
|
|
7
|
+
* live, and which promotion is it?" — across a three-step lookup: the legacy
|
|
8
|
+
* `promotions.code` column first, then the `promotion_coupons` table, then the
|
|
9
|
+
* promotion behind the coupon, each filtered on `isActive`. That order and
|
|
10
|
+
* those filters are this module's business, and reproducing them in `carts`
|
|
11
|
+
* meant reproducing them correctly.
|
|
12
|
+
*
|
|
13
|
+
* The answer is eight fields rather than the thirty-column row, because eight
|
|
14
|
+
* is what the question needs: which promotion, and — when the buyer may not
|
|
15
|
+
* use it — why not. The four eligibility fields joined the record in feature
|
|
16
|
+
* 075's `carts` cut; without them the caller could only answer `invalid_code`
|
|
17
|
+
* where it answers `expired`, `wrong_organization` and `below_min_spend`.
|
|
18
|
+
*/
|
|
19
|
+
export class PromotionCodeService {
|
|
20
|
+
emFactory;
|
|
21
|
+
constructor(emFactory) {
|
|
22
|
+
this.emFactory = emFactory;
|
|
23
|
+
}
|
|
24
|
+
async resolveByCode(code) {
|
|
25
|
+
const em = this.emFactory();
|
|
26
|
+
// Legacy inline code on the promotion itself.
|
|
27
|
+
const legacy = await em.findOne(Promotion, { code, isActive: true });
|
|
28
|
+
if (legacy)
|
|
29
|
+
return toResolvedCode(legacy, null);
|
|
30
|
+
const coupon = await em.findOne(PromotionCoupon, { code, isActive: true });
|
|
31
|
+
if (!coupon)
|
|
32
|
+
return null;
|
|
33
|
+
// A live coupon whose promotion is not live is not a live code. Answering
|
|
34
|
+
// the coupon alone would let a cart accept a code that discounts nothing.
|
|
35
|
+
const promotion = await em.findOne(Promotion, { id: coupon.promotionId, isActive: true });
|
|
36
|
+
if (!promotion)
|
|
37
|
+
return null;
|
|
38
|
+
return toResolvedCode(promotion, coupon.id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function toResolvedCode(promotion, couponId) {
|
|
42
|
+
return {
|
|
43
|
+
promotionId: promotion.id,
|
|
44
|
+
couponId,
|
|
45
|
+
name: promotion.name,
|
|
46
|
+
isActive: true,
|
|
47
|
+
validFrom: promotion.validFrom ?? null,
|
|
48
|
+
validUntil: promotion.validUntil ?? null,
|
|
49
|
+
organizationId: promotion.organizationId ?? null,
|
|
50
|
+
minCartSubtotal: promotion.minCartSubtotal ?? null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=promotion-code-port.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-code-port.js","sourceRoot":"","sources":["../../../src/backend/services/promotion-code-port.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,wCAAwC,CAAC;AAEzE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,OAAO,oBAAoB;IACF;IAA7B,YAA6B,SAA8B;QAA9B,cAAS,GAAT,SAAS,CAAqB;IAAG,CAAC;IAE/D,KAAK,CAAC,aAAa,CAAC,IAAY;QAC9B,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAE5B,8CAA8C;QAC9C,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QACrE,IAAI,MAAM;YAAE,OAAO,cAAc,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAEhD,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3E,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QAEzB,0EAA0E;QAC1E,0EAA0E;QAC1E,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QAC1F,IAAI,CAAC,SAAS;YAAE,OAAO,IAAI,CAAC;QAE5B,OAAO,cAAc,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;IAC9C,CAAC;CACF;AAED,SAAS,cAAc,CAAC,SAAoB,EAAE,QAAuB;IACnE,OAAO;QACL,WAAW,EAAE,SAAS,CAAC,EAAE;QACzB,QAAQ;QACR,IAAI,EAAE,SAAS,CAAC,IAAI;QACpB,QAAQ,EAAE,IAAI;QACd,SAAS,EAAE,SAAS,CAAC,SAAS,IAAI,IAAI;QACtC,UAAU,EAAE,SAAS,CAAC,UAAU,IAAI,IAAI;QACxC,cAAc,EAAE,SAAS,CAAC,cAAc,IAAI,IAAI;QAChD,eAAe,EAAE,SAAS,CAAC,eAAe,IAAI,IAAI;KACnD,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DictionaryReferenceRegistryPort } from '@endora-commerce/contracts';
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
3
|
+
/**
|
|
4
|
+
* Who in `promotions` still points at a currency (feature 077, D-87).
|
|
5
|
+
*
|
|
6
|
+
* `currencies` used to ask this with `select count(*) from "promotions"` of
|
|
7
|
+
* its own — this module's table, named in a string no import-level boundary
|
|
8
|
+
* check can see. The question is answered here now, against the table this
|
|
9
|
+
* module owns, and pushed into `currencyReferenceRegistry` from the boot
|
|
10
|
+
* hook.
|
|
11
|
+
*/
|
|
12
|
+
export declare function registerPromotionCurrencyReferences(registry: DictionaryReferenceRegistryPort, emFactory: () => EntityManager): void;
|
|
13
|
+
//# sourceMappingURL=promotion-currency-reference.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-currency-reference.d.ts","sourceRoot":"","sources":["../../../src/backend/services/promotion-currency-reference.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,+BAA+B,EAAE,MAAM,4BAA4B,CAAC;AAClF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D;;;;;;;;GAQG;AACH,wBAAgB,mCAAmC,CACjD,QAAQ,EAAE,+BAA+B,EACzC,SAAS,EAAE,MAAM,aAAa,GAC7B,IAAI,CAoBN"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who in `promotions` still points at a currency (feature 077, D-87).
|
|
3
|
+
*
|
|
4
|
+
* `currencies` used to ask this with `select count(*) from "promotions"` of
|
|
5
|
+
* its own — this module's table, named in a string no import-level boundary
|
|
6
|
+
* check can see. The question is answered here now, against the table this
|
|
7
|
+
* module owns, and pushed into `currencyReferenceRegistry` from the boot
|
|
8
|
+
* hook.
|
|
9
|
+
*/
|
|
10
|
+
export function registerPromotionCurrencyReferences(registry, emFactory) {
|
|
11
|
+
registry.register({
|
|
12
|
+
ownerModuleId: 'promotions',
|
|
13
|
+
consumer: 'promotions',
|
|
14
|
+
tableName: 'promotions',
|
|
15
|
+
columnName: 'currency',
|
|
16
|
+
blocking: true,
|
|
17
|
+
countReferences: async (code) => {
|
|
18
|
+
const rows = (await emFactory().execute(`select count(*)::int as n from "promotions" where "currency" = ?`, [code]));
|
|
19
|
+
return rows[0]?.n ?? 0;
|
|
20
|
+
},
|
|
21
|
+
usedCodes: async () => (await emFactory().execute(`select "currency" as code, count(*)::int as count
|
|
22
|
+
from "promotions" where "currency" is not null group by "currency"`)),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=promotion-currency-reference.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-currency-reference.js","sourceRoot":"","sources":["../../../src/backend/services/promotion-currency-reference.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH,MAAM,UAAU,mCAAmC,CACjD,QAAyC,EACzC,SAA8B;IAE9B,QAAQ,CAAC,QAAQ,CAAC;QAChB,aAAa,EAAE,YAAY;QAC3B,QAAQ,EAAE,YAAY;QACtB,SAAS,EAAE,YAAY;QACvB,UAAU,EAAE,UAAU;QACtB,QAAQ,EAAE,IAAI;QACd,eAAe,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;YAC9B,MAAM,IAAI,GAAG,CAAC,MAAM,SAAS,EAAE,CAAC,OAAO,CACrC,kEAAkE,EAClE,CAAC,IAAI,CAAC,CACP,CAAyB,CAAC;YAC3B,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;QACzB,CAAC;QACD,SAAS,EAAE,KAAK,IAAI,EAAE,CACpB,CAAC,MAAM,SAAS,EAAE,CAAC,OAAO,CACxB;8EACsE,CACvE,CAA2C;KAC/C,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PromotionRule, PromotionRuleValue } from '@endora-commerce/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Feature 045 — pure evaluator for the typed promotion Rule AST.
|
|
4
|
+
*
|
|
5
|
+
* `evaluatePromotionRule(rule, ctx)` returns whether a cart satisfies the
|
|
6
|
+
* rule. It is pure (no clock, no I/O): the caller assembles the
|
|
7
|
+
* `PromotionRuleContext` from the cart snapshot. Attribute conditions are
|
|
8
|
+
* evaluated against an aggregated per-key value view of the cart; a
|
|
9
|
+
* condition over an attribute that has lost its promo-eligibility flag is
|
|
10
|
+
* treated as non-matching and reported through `onSkip` (FR-039 parity).
|
|
11
|
+
*/
|
|
12
|
+
export interface PromotionRuleContext {
|
|
13
|
+
cartTotal: number;
|
|
14
|
+
paymentMethodCode: string | null;
|
|
15
|
+
deliveryMethodCode: string | null;
|
|
16
|
+
deliveryCountry: string | null;
|
|
17
|
+
deliveryPostalCode: string | null;
|
|
18
|
+
organizationId: string | null;
|
|
19
|
+
customerGroupId: string | null;
|
|
20
|
+
/** Union of every line's category ids. */
|
|
21
|
+
categoryIds: readonly string[];
|
|
22
|
+
/** Per-attribute-key list of values present across cart lines. */
|
|
23
|
+
attributeValues: Readonly<Record<string, readonly PromotionRuleValue[]>>;
|
|
24
|
+
/** When provided, an attribute returning `false` is skipped (treated non-matching). */
|
|
25
|
+
isPromoEligibleAttribute?: (key: string) => boolean;
|
|
26
|
+
onSkip?: (info: {
|
|
27
|
+
attributeKey: string;
|
|
28
|
+
reason: 'attribute_not_promo_eligible';
|
|
29
|
+
}) => void;
|
|
30
|
+
}
|
|
31
|
+
export declare function evaluatePromotionRule(rule: PromotionRule, ctx: PromotionRuleContext): boolean;
|
|
32
|
+
//# sourceMappingURL=promotion-rule-evaluator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-rule-evaluator.d.ts","sourceRoot":"","sources":["../../../src/backend/services/promotion-rule-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EAGb,kBAAkB,EACnB,MAAM,4BAA4B,CAAC;AAEpC;;;;;;;;;GASG;AACH,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0CAA0C;IAC1C,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,kEAAkE;IAClE,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,kBAAkB,EAAE,CAAC,CAAC,CAAC;IACzE,uFAAuF;IACvF,wBAAwB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACpD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,8BAA8B,CAAA;KAAE,KAAK,IAAI,CAAC;CAC3F;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,oBAAoB,GAAG,OAAO,CAa7F"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export function evaluatePromotionRule(rule, ctx) {
|
|
2
|
+
switch (rule.kind) {
|
|
3
|
+
case 'all':
|
|
4
|
+
return true;
|
|
5
|
+
case 'group':
|
|
6
|
+
return rule.op === 'AND'
|
|
7
|
+
? rule.children.every((child) => evaluatePromotionRule(child, ctx))
|
|
8
|
+
: rule.children.some((child) => evaluatePromotionRule(child, ctx));
|
|
9
|
+
case 'condition':
|
|
10
|
+
return evaluateCondition(rule, ctx);
|
|
11
|
+
default:
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function evaluateCondition(cond, ctx) {
|
|
16
|
+
if (cond.field.kind === 'attribute') {
|
|
17
|
+
const key = cond.field.attributeKey;
|
|
18
|
+
if (ctx.isPromoEligibleAttribute && !ctx.isPromoEligibleAttribute(key)) {
|
|
19
|
+
ctx.onSkip?.({ attributeKey: key, reason: 'attribute_not_promo_eligible' });
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const present = ctx.attributeValues[key] ?? [];
|
|
23
|
+
// Set-style operators compare the whole present-value set; scalar
|
|
24
|
+
// operators match if ANY present value satisfies the condition.
|
|
25
|
+
if (cond.op === 'in')
|
|
26
|
+
return present.some((v) => cond.values.includes(v));
|
|
27
|
+
if (cond.op === 'notIn')
|
|
28
|
+
return !present.some((v) => cond.values.includes(v));
|
|
29
|
+
return present.some((v) => matchScalar(v, cond.op, cond.values));
|
|
30
|
+
}
|
|
31
|
+
switch (cond.field.key) {
|
|
32
|
+
case 'cartTotal':
|
|
33
|
+
return matchScalar(ctx.cartTotal, cond.op, cond.values);
|
|
34
|
+
case 'paymentMethod':
|
|
35
|
+
return matchScalar(ctx.paymentMethodCode, cond.op, cond.values);
|
|
36
|
+
case 'deliveryMethod':
|
|
37
|
+
return matchScalar(ctx.deliveryMethodCode, cond.op, cond.values);
|
|
38
|
+
case 'deliveryCountry':
|
|
39
|
+
return matchScalar(ctx.deliveryCountry, cond.op, cond.values);
|
|
40
|
+
case 'deliveryPostalCode':
|
|
41
|
+
return matchScalar(ctx.deliveryPostalCode, cond.op, cond.values);
|
|
42
|
+
case 'organization':
|
|
43
|
+
return matchScalar(ctx.organizationId, cond.op, cond.values);
|
|
44
|
+
case 'customerGroup':
|
|
45
|
+
return matchScalar(ctx.customerGroupId, cond.op, cond.values);
|
|
46
|
+
case 'category':
|
|
47
|
+
return matchSet(ctx.categoryIds, cond.op, cond.values);
|
|
48
|
+
default:
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Set membership for multi-valued subjects (e.g. category ids). */
|
|
53
|
+
function matchSet(subject, op, values) {
|
|
54
|
+
const intersects = subject.some((s) => values.includes(s));
|
|
55
|
+
switch (op) {
|
|
56
|
+
case 'in':
|
|
57
|
+
case 'eq':
|
|
58
|
+
return intersects;
|
|
59
|
+
case 'notIn':
|
|
60
|
+
case 'neq':
|
|
61
|
+
return !intersects;
|
|
62
|
+
default:
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Scalar comparison for a single (possibly null) subject. */
|
|
67
|
+
function matchScalar(subject, op, values) {
|
|
68
|
+
switch (op) {
|
|
69
|
+
case 'eq':
|
|
70
|
+
return values.some((v) => looseEq(subject, v));
|
|
71
|
+
case 'neq':
|
|
72
|
+
return subject != null && !values.some((v) => looseEq(subject, v));
|
|
73
|
+
case 'in':
|
|
74
|
+
return subject != null && values.some((v) => looseEq(subject, v));
|
|
75
|
+
case 'notIn':
|
|
76
|
+
return subject != null && !values.some((v) => looseEq(subject, v));
|
|
77
|
+
case 'gt':
|
|
78
|
+
case 'gte':
|
|
79
|
+
case 'lt':
|
|
80
|
+
case 'lte':
|
|
81
|
+
return compareNumeric(subject, op, values[0]);
|
|
82
|
+
case 'between': {
|
|
83
|
+
const num = toNumber(subject);
|
|
84
|
+
const lo = toNumber(values[0]);
|
|
85
|
+
const hi = toNumber(values[1]);
|
|
86
|
+
if (num == null || lo == null || hi == null)
|
|
87
|
+
return false;
|
|
88
|
+
return num >= lo && num <= hi;
|
|
89
|
+
}
|
|
90
|
+
case 'contains':
|
|
91
|
+
return subject != null && values.some((v) => String(subject).includes(String(v)));
|
|
92
|
+
case 'startsWith':
|
|
93
|
+
return subject != null && values.some((v) => String(subject).startsWith(String(v)));
|
|
94
|
+
default:
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function compareNumeric(subject, op, value) {
|
|
99
|
+
const a = toNumber(subject);
|
|
100
|
+
const b = toNumber(value);
|
|
101
|
+
if (a == null || b == null)
|
|
102
|
+
return false;
|
|
103
|
+
switch (op) {
|
|
104
|
+
case 'gt':
|
|
105
|
+
return a > b;
|
|
106
|
+
case 'gte':
|
|
107
|
+
return a >= b;
|
|
108
|
+
case 'lt':
|
|
109
|
+
return a < b;
|
|
110
|
+
case 'lte':
|
|
111
|
+
return a <= b;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function toNumber(v) {
|
|
115
|
+
if (v == null)
|
|
116
|
+
return null;
|
|
117
|
+
const n = typeof v === 'boolean' ? (v ? 1 : 0) : Number(v);
|
|
118
|
+
return Number.isFinite(n) ? n : null;
|
|
119
|
+
}
|
|
120
|
+
function looseEq(a, b) {
|
|
121
|
+
if (a == null)
|
|
122
|
+
return false;
|
|
123
|
+
if (typeof a === 'number' || typeof b === 'number') {
|
|
124
|
+
const na = toNumber(a);
|
|
125
|
+
const nb = toNumber(b);
|
|
126
|
+
if (na != null && nb != null)
|
|
127
|
+
return na === nb;
|
|
128
|
+
}
|
|
129
|
+
return String(a) === String(b);
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=promotion-rule-evaluator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-rule-evaluator.js","sourceRoot":"","sources":["../../../src/backend/services/promotion-rule-evaluator.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CartLine, PromotionCriterion } from '@endora-commerce/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Feature 012 / US8 — pure rule evaluator for the promotions module.
|
|
4
|
+
*
|
|
5
|
+
* `matches(criterion, line, ctx)` returns `true` iff the cart line
|
|
6
|
+
* satisfies the criterion. Today only the `attribute` variant carries
|
|
7
|
+
* line-level meaning; the other variants are routed through the
|
|
8
|
+
* existing flat eligibility filters in `PromotionService`.
|
|
9
|
+
*
|
|
10
|
+
* The function is pure — no clock, no I/O — so identical inputs always
|
|
11
|
+
* produce identical outputs. Attribute lookups MUST be passed in via
|
|
12
|
+
* `ctx.attributeLookup` to keep the evaluator deterministic and
|
|
13
|
+
* testable. The lookup is built once per `applyToCart` call.
|
|
14
|
+
*
|
|
15
|
+
* Skip-on-toggle (FR-039): when the looked-up attribute carries
|
|
16
|
+
* `isPromoRule = false`, the criterion is treated as `false` and the
|
|
17
|
+
* decision is logged once at `info` via `ctx.onSkip`.
|
|
18
|
+
*/
|
|
19
|
+
export interface PromotionRuleAttributeLookup {
|
|
20
|
+
/**
|
|
21
|
+
* Returns the attribute's metadata (with options) by key, or `null`
|
|
22
|
+
* if the attribute does not exist. The lookup is expected to be
|
|
23
|
+
* pre-populated by `PromotionService.applyToCart` for every key any
|
|
24
|
+
* active promotion's criteria reference.
|
|
25
|
+
*/
|
|
26
|
+
get(key: string): {
|
|
27
|
+
id: string;
|
|
28
|
+
key: string;
|
|
29
|
+
valueType: 'string' | 'number' | 'boolean' | 'date' | 'enum' | 'select' | 'multiselect' | 'price';
|
|
30
|
+
isPromoRule: boolean;
|
|
31
|
+
options: Array<{
|
|
32
|
+
value: string;
|
|
33
|
+
}>;
|
|
34
|
+
} | null;
|
|
35
|
+
}
|
|
36
|
+
export interface PromotionRuleEvaluationContext {
|
|
37
|
+
attributeLookup: PromotionRuleAttributeLookup;
|
|
38
|
+
/** Hook fired when a criterion is skipped due to FR-039. */
|
|
39
|
+
onSkip?: (info: {
|
|
40
|
+
promotionId: string;
|
|
41
|
+
criterionAttributeKey: string;
|
|
42
|
+
reason: 'attribute_not_promo_eligible' | 'attribute_not_found';
|
|
43
|
+
}) => void;
|
|
44
|
+
/** Identifier passed to `onSkip` so the operator can correlate. */
|
|
45
|
+
promotionId: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Per-criterion match. Returns `true` if the line satisfies the
|
|
49
|
+
* criterion. For `attribute` criteria with `attributeValues` available
|
|
50
|
+
* on the cart line, the lookup is consulted to determine the operator
|
|
51
|
+
* vocabulary; if the attribute is missing or has `isPromoRule=false`
|
|
52
|
+
* the criterion is silently skipped (treated as `false` per FR-039).
|
|
53
|
+
*/
|
|
54
|
+
export declare function matches(criterion: PromotionCriterion, line: CartLine, ctx: PromotionRuleEvaluationContext): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* `lineMatchesAllCriteria(criteria, line, ctx)` — true iff every
|
|
57
|
+
* `attribute` / `category` / `product` criterion in the array matches
|
|
58
|
+
* the line. Cart-level criteria (`customerGroup`, `organization`) are
|
|
59
|
+
* always treated as `true` here (routed at the cart level instead).
|
|
60
|
+
*/
|
|
61
|
+
export declare function lineMatchesAllCriteria(criteria: readonly PromotionCriterion[], line: CartLine, ctx: PromotionRuleEvaluationContext): boolean;
|
|
62
|
+
//# sourceMappingURL=promotion-rule-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-rule-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/promotion-rule-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAE/E;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,GAAG,EAAE,MAAM,CAAC;QACZ,SAAS,EACL,QAAQ,GACR,QAAQ,GACR,SAAS,GACT,MAAM,GACN,MAAM,GACN,QAAQ,GACR,aAAa,GACb,OAAO,CAAC;QACZ,WAAW,EAAE,OAAO,CAAC;QACrB,OAAO,EAAE,KAAK,CAAC;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KACnC,GAAG,IAAI,CAAC;CACV;AAED,MAAM,WAAW,8BAA8B;IAC7C,eAAe,EAAE,4BAA4B,CAAC;IAC9C,4DAA4D;IAC5D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE;QACd,WAAW,EAAE,MAAM,CAAC;QACpB,qBAAqB,EAAE,MAAM,CAAC;QAC9B,MAAM,EAAE,8BAA8B,GAAG,qBAAqB,CAAC;KAChE,KAAK,IAAI,CAAC;IACX,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CACrB,SAAS,EAAE,kBAAkB,EAC7B,IAAI,EAAE,QAAQ,EACd,GAAG,EAAE,8BAA8B,GAClC,OAAO,CA6DT;AA2BD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS,kBAAkB,EAAE,EACvC,IAAI,EAAE,QAAQ,EACd,GAAG,EAAE,8BAA8B,GAClC,OAAO,CAKT"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-criterion match. Returns `true` if the line satisfies the
|
|
3
|
+
* criterion. For `attribute` criteria with `attributeValues` available
|
|
4
|
+
* on the cart line, the lookup is consulted to determine the operator
|
|
5
|
+
* vocabulary; if the attribute is missing or has `isPromoRule=false`
|
|
6
|
+
* the criterion is silently skipped (treated as `false` per FR-039).
|
|
7
|
+
*/
|
|
8
|
+
export function matches(criterion, line, ctx) {
|
|
9
|
+
if (criterion.type === 'category') {
|
|
10
|
+
return criterion.categoryIds.some((id) => line.categoryIds.includes(id));
|
|
11
|
+
}
|
|
12
|
+
if (criterion.type === 'product') {
|
|
13
|
+
return criterion.productIds.includes(line.productId);
|
|
14
|
+
}
|
|
15
|
+
if (criterion.type === 'customerGroup' || criterion.type === 'organization') {
|
|
16
|
+
// Routed at the cart level; not a line-level filter.
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
// criterion.type === 'attribute'
|
|
20
|
+
const meta = ctx.attributeLookup.get(criterion.attributeKey);
|
|
21
|
+
if (!meta) {
|
|
22
|
+
ctx.onSkip?.({
|
|
23
|
+
promotionId: ctx.promotionId,
|
|
24
|
+
criterionAttributeKey: criterion.attributeKey,
|
|
25
|
+
reason: 'attribute_not_found',
|
|
26
|
+
});
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!meta.isPromoRule) {
|
|
30
|
+
ctx.onSkip?.({
|
|
31
|
+
promotionId: ctx.promotionId,
|
|
32
|
+
criterionAttributeKey: criterion.attributeKey,
|
|
33
|
+
reason: 'attribute_not_promo_eligible',
|
|
34
|
+
});
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const raw = line.attributeValues?.[criterion.attributeKey];
|
|
38
|
+
if (raw == null)
|
|
39
|
+
return false;
|
|
40
|
+
switch (criterion.op) {
|
|
41
|
+
case 'equals':
|
|
42
|
+
return scalarEquals(raw, criterion.values[0], meta.valueType);
|
|
43
|
+
case 'in':
|
|
44
|
+
if (Array.isArray(raw)) {
|
|
45
|
+
// multiselect — any-of semantics
|
|
46
|
+
return raw.some((v) => criterion.values.includes(v));
|
|
47
|
+
}
|
|
48
|
+
return criterion.values.includes(raw);
|
|
49
|
+
case 'range': {
|
|
50
|
+
const [min, max] = criterion.values;
|
|
51
|
+
if (meta.valueType === 'date') {
|
|
52
|
+
const r = Date.parse(String(raw));
|
|
53
|
+
const lo = Date.parse(String(min));
|
|
54
|
+
const hi = Date.parse(String(max));
|
|
55
|
+
if (!Number.isFinite(r) || !Number.isFinite(lo) || !Number.isFinite(hi))
|
|
56
|
+
return false;
|
|
57
|
+
return r >= lo && r <= hi;
|
|
58
|
+
}
|
|
59
|
+
const num = Number(raw);
|
|
60
|
+
const lo = Number(min);
|
|
61
|
+
const hi = Number(max);
|
|
62
|
+
if (!Number.isFinite(num) || !Number.isFinite(lo) || !Number.isFinite(hi))
|
|
63
|
+
return false;
|
|
64
|
+
return num >= lo && num <= hi;
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function scalarEquals(raw, expected, valueType) {
|
|
71
|
+
if (valueType === 'number' || valueType === 'price') {
|
|
72
|
+
return Number(raw) === Number(expected);
|
|
73
|
+
}
|
|
74
|
+
if (valueType === 'boolean') {
|
|
75
|
+
return Boolean(raw) === Boolean(expected);
|
|
76
|
+
}
|
|
77
|
+
if (valueType === 'multiselect' && Array.isArray(raw)) {
|
|
78
|
+
return raw.length === 1 && raw[0] === expected;
|
|
79
|
+
}
|
|
80
|
+
return String(raw) === String(expected);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `lineMatchesAllCriteria(criteria, line, ctx)` — true iff every
|
|
84
|
+
* `attribute` / `category` / `product` criterion in the array matches
|
|
85
|
+
* the line. Cart-level criteria (`customerGroup`, `organization`) are
|
|
86
|
+
* always treated as `true` here (routed at the cart level instead).
|
|
87
|
+
*/
|
|
88
|
+
export function lineMatchesAllCriteria(criteria, line, ctx) {
|
|
89
|
+
for (const c of criteria) {
|
|
90
|
+
if (!matches(c, line, ctx))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=promotion-rule-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-rule-service.js","sourceRoot":"","sources":["../../../src/backend/services/promotion-rule-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import { type PromotionRule } from '@endora-commerce/contracts';
|
|
3
|
+
import { PromotionRuleEntity } from '../entities/promotion-rule.entity.js';
|
|
4
|
+
import type { AuditPort } from '@endora-commerce/platform/kernel';
|
|
5
|
+
/**
|
|
6
|
+
* Feature 045 (US6) — CRUD for standalone, named promotion rules and the
|
|
7
|
+
* dependency lookup used to surface / block deletion of in-use rules.
|
|
8
|
+
*/
|
|
9
|
+
export declare class PromotionRuleStore {
|
|
10
|
+
#private;
|
|
11
|
+
private readonly emFactory;
|
|
12
|
+
private readonly auditLog?;
|
|
13
|
+
constructor(emFactory: () => EntityManager, auditLog?: AuditPort | undefined);
|
|
14
|
+
list(): Promise<PromotionRuleEntity[]>;
|
|
15
|
+
getById(id: string): Promise<PromotionRuleEntity>;
|
|
16
|
+
/** Promotions referencing a named rule, for dependency surfacing (FR-019). */
|
|
17
|
+
usedBy(id: string): Promise<Array<{
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
}>>;
|
|
21
|
+
create(input: {
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string | null;
|
|
24
|
+
definition: PromotionRule;
|
|
25
|
+
}): Promise<PromotionRuleEntity>;
|
|
26
|
+
update(id: string, input: {
|
|
27
|
+
name: string;
|
|
28
|
+
description?: string | null;
|
|
29
|
+
definition: PromotionRule;
|
|
30
|
+
}): Promise<PromotionRuleEntity>;
|
|
31
|
+
remove(id: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=promotion-rule-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-rule-store.d.ts","sourceRoot":"","sources":["../../../src/backend/services/promotion-rule-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAE7E,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAG3E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE;;;GAGG;AACH,qBAAa,kBAAkB;;IAE3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBADT,SAAS,EAAE,MAAM,aAAa,EAC9B,QAAQ,CAAC,EAAE,SAAS,YAAA;IAqBjC,IAAI,IAAI,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAItC,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAMvD,8EAA8E;IACxE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAKhE,MAAM,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,aAAa,CAAA;KAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAiBrH,MAAM,CACV,EAAE,EAAE,MAAM,EACV,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,aAAa,CAAA;KAAE,GAC9E,OAAO,CAAC,mBAAmB,CAAC;IAgBzB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAexC"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ERROR_CODES } from '@endora-commerce/contracts';
|
|
2
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
3
|
+
import { PromotionRuleEntity } from '../entities/promotion-rule.entity.js';
|
|
4
|
+
import { Promotion } from '../entities/promotion.entity.js';
|
|
5
|
+
import { recordAuditFromContext } from '@endora-commerce/platform/commands';
|
|
6
|
+
/**
|
|
7
|
+
* Feature 045 (US6) — CRUD for standalone, named promotion rules and the
|
|
8
|
+
* dependency lookup used to surface / block deletion of in-use rules.
|
|
9
|
+
*/
|
|
10
|
+
export class PromotionRuleStore {
|
|
11
|
+
emFactory;
|
|
12
|
+
auditLog;
|
|
13
|
+
constructor(emFactory, auditLog) {
|
|
14
|
+
this.emFactory = emFactory;
|
|
15
|
+
this.auditLog = auditLog;
|
|
16
|
+
}
|
|
17
|
+
#audit(em, action, objectId, stateBefore, stateAfter) {
|
|
18
|
+
if (this.auditLog) {
|
|
19
|
+
recordAuditFromContext(this.auditLog, em, {
|
|
20
|
+
action,
|
|
21
|
+
objectType: 'promotion_rule',
|
|
22
|
+
objectId,
|
|
23
|
+
stateBefore,
|
|
24
|
+
stateAfter,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async list() {
|
|
29
|
+
return this.emFactory().find(PromotionRuleEntity, {}, { orderBy: { name: 'asc' } });
|
|
30
|
+
}
|
|
31
|
+
async getById(id) {
|
|
32
|
+
const row = await this.emFactory().findOne(PromotionRuleEntity, { id });
|
|
33
|
+
if (!row)
|
|
34
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Rule ${id} not found.`);
|
|
35
|
+
return row;
|
|
36
|
+
}
|
|
37
|
+
/** Promotions referencing a named rule, for dependency surfacing (FR-019). */
|
|
38
|
+
async usedBy(id) {
|
|
39
|
+
const rows = await this.emFactory().find(Promotion, { ruleId: id }, { fields: ['id', 'name'] });
|
|
40
|
+
return rows.map((p) => ({ id: p.id, name: p.name }));
|
|
41
|
+
}
|
|
42
|
+
async create(input) {
|
|
43
|
+
const em = this.emFactory();
|
|
44
|
+
const existing = await em.findOne(PromotionRuleEntity, { name: input.name });
|
|
45
|
+
if (existing) {
|
|
46
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, `rule_name_taken: ${input.name}`);
|
|
47
|
+
}
|
|
48
|
+
const row = em.create(PromotionRuleEntity, {
|
|
49
|
+
name: input.name,
|
|
50
|
+
description: input.description ?? null,
|
|
51
|
+
definition: input.definition,
|
|
52
|
+
});
|
|
53
|
+
em.persist(row);
|
|
54
|
+
this.#audit(em, 'promotion_rule.create', row.id, null, { name: row.name });
|
|
55
|
+
await em.flush();
|
|
56
|
+
return row;
|
|
57
|
+
}
|
|
58
|
+
async update(id, input) {
|
|
59
|
+
const em = this.emFactory();
|
|
60
|
+
const row = await em.findOne(PromotionRuleEntity, { id });
|
|
61
|
+
if (!row)
|
|
62
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Rule ${id} not found.`);
|
|
63
|
+
if (input.name !== row.name) {
|
|
64
|
+
const clash = await em.findOne(PromotionRuleEntity, { name: input.name });
|
|
65
|
+
if (clash)
|
|
66
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, `rule_name_taken: ${input.name}`);
|
|
67
|
+
}
|
|
68
|
+
row.name = input.name;
|
|
69
|
+
row.description = input.description ?? null;
|
|
70
|
+
row.definition = input.definition;
|
|
71
|
+
this.#audit(em, 'promotion_rule.update', row.id, null, { name: row.name });
|
|
72
|
+
await em.flush();
|
|
73
|
+
return row;
|
|
74
|
+
}
|
|
75
|
+
async remove(id) {
|
|
76
|
+
const em = this.emFactory();
|
|
77
|
+
const row = await em.findOne(PromotionRuleEntity, { id });
|
|
78
|
+
if (!row)
|
|
79
|
+
return;
|
|
80
|
+
const inUse = await this.usedBy(id);
|
|
81
|
+
if (inUse.length > 0) {
|
|
82
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, `rule_in_use: referenced by ${inUse.length} promotion(s)`);
|
|
83
|
+
}
|
|
84
|
+
this.#audit(em, 'promotion_rule.delete', row.id, { name: row.name }, null);
|
|
85
|
+
await em.removeAndFlush(row);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=promotion-rule-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promotion-rule-store.js","sourceRoot":"","sources":["../../../src/backend/services/promotion-rule-store.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAsB,MAAM,4BAA4B,CAAC;AAC7E,OAAO,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAC5D,OAAO,EAAE,sBAAsB,EAAE,MAAM,oCAAoC,CAAC;AAG5E;;;GAGG;AACH,MAAM,OAAO,kBAAkB;IAEV;IACA;IAFnB,YACmB,SAA8B,EAC9B,QAAoB;QADpB,cAAS,GAAT,SAAS,CAAqB;QAC9B,aAAQ,GAAR,QAAQ,CAAY;IACpC,CAAC;IAEJ,MAAM,CACJ,EAAiB,EACjB,MAAc,EACd,QAAgB,EAChB,WAA2C,EAC3C,UAA0C;QAE1C,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YAClB,sBAAsB,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,EAAE;gBACxC,MAAM;gBACN,UAAU,EAAE,gBAAgB;gBAC5B,QAAQ;gBACR,WAAW;gBACX,UAAU;aACX,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,KAAK,CAAC,IAAI;QACR,OAAO,IAAI,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,mBAAmB,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;IACtF,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,EAAU;QACtB,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;QACxE,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC,SAAS,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAC;QACnF,OAAO,GAAG,CAAC;IACb,CAAC;IAED,8EAA8E;IAC9E,KAAK,CAAC,MAAM,CAAC,EAAU;QACrB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QAChG,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IACvD,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,KAA+E;QAC1F,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QAC7E,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,IAAI,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC,iBAAiB,EAAE,oBAAoB,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QAC5F,CAAC;QACD,MAAM,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,mBAAmB,EAAE;YACzC,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,WAAW,EAAE,KAAK,CAAC,WAAW,IAAI,IAAI;YACtC,UAAU,EAAE,KAAK,CAAC,UAAU;SAC7B,CAAC,CAAC;QACH,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAChB,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,uBAAuB,EAAE,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;QAC3E,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,CAAC,MAAM,CACV,EAAU,EACV,KAA+E;QAE/E,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;QAC1D,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC,SAAS,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAC;QACnF,IAAI,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,EAAE,CAAC;YAC5B,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;YAC1E,IAAI,KAAK;gBAAE,MAAM,IAAI,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC,iBAAiB,EAAE,oBAAoB,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QACvG,CAAC;QACD,GAAG,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QACtB,GAAG,CAAC,WAAW,GAAG,KAAK,CAAC,WAAW,IAAI,IAAI,CAAC;QAC5C,GAAG,CAAC,UAAU,GAAG,KAAK,CAAC,UAAU,CAAC;QAClC,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,uBAAuB,EAAE,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;QAC3E,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,EAAU;QACrB,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;QAC1D,IAAI,CAAC,GAAG;YAAE,OAAO;QACjB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACpC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACrB,MAAM,IAAI,SAAS,CACjB,GAAG,EACH,WAAW,CAAC,iBAAiB,EAC7B,8BAA8B,KAAK,CAAC,MAAM,eAAe,CAC1D,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,uBAAuB,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC;QAC3E,MAAM,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC;IAC/B,CAAC;CACF"}
|