@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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `./migrations` subpath — every migration class this module owns, as one
|
|
3
|
+
* ordered `migrations` array.
|
|
4
|
+
*
|
|
5
|
+
* The array is what the platform reads when this module is **installed**:
|
|
6
|
+
* `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
|
|
7
|
+
* the package outright when it is absent — *"the `./migrations` export of
|
|
8
|
+
* @endora-commerce/mod-promotions exports no 'migrations' array"* (D-168).
|
|
9
|
+
*
|
|
10
|
+
* **Listed in ascending timestamp, which orders this module's own migrations and
|
|
11
|
+
* nothing else** (feature 081). Where this block sits relative to
|
|
12
|
+
* every other module's is decided by the manifest `dependencies` graph:
|
|
13
|
+
* `promotions` declares `orders`, and
|
|
14
|
+
* `…T081251_…_promotion_usage_order_fk` is why — it adds
|
|
15
|
+
* `promotion_usages_order_fk` (`promotion_usages.order_id` -> `orders.id`,
|
|
16
|
+
* `on delete restrict`), so `orders`' tables must already exist when this block
|
|
17
|
+
* runs. A foreign key needs the **table**, never the owner's entity class
|
|
18
|
+
* (D-169), which is what lets that constraint stand while `./backend` publishes
|
|
19
|
+
* no entity class by name.
|
|
20
|
+
*
|
|
21
|
+
* The **named** exports stay, and the asymmetry with `./backend` — which
|
|
22
|
+
* publishes an array and no named class (D-168) — is deliberate.
|
|
23
|
+
* `db/migrations-registry.generated.ts` imports each class by name from this
|
|
24
|
+
* specifier and hands it to `migration('promotions', …)`, and a migration class
|
|
25
|
+
* name is contract in a way an entity class name is not:
|
|
26
|
+
* `mikro_orm_migrations` persists it, so it is a string every already-migrated
|
|
27
|
+
* database holds.
|
|
28
|
+
*
|
|
29
|
+
* A class that is in neither the array nor the barrel is a migration that does
|
|
30
|
+
* not run: `migration:pending` reports nothing pending and the first symptom is
|
|
31
|
+
* a query against a table nobody created.
|
|
32
|
+
*/
|
|
33
|
+
import { Migration20260505T074605PromotionsCriteria } from './20260505T074605_promotions_criteria.js';
|
|
34
|
+
import { Migration20260618T100727PromotionsEngine } from './20260618T100727_promotions_engine.js';
|
|
35
|
+
import { Migration20260818T081251PromotionsPromotionUsageOrderFk } from './20260818T081251_promotions_promotion_usage_order_fk.js';
|
|
36
|
+
import { Migration20260912T094715PromotionsSalesChannelPromotions } from './20260912T094715_promotions_sales_channel_promotions.js';
|
|
37
|
+
export const migrations = [
|
|
38
|
+
Migration20260505T074605PromotionsCriteria,
|
|
39
|
+
Migration20260618T100727PromotionsEngine,
|
|
40
|
+
Migration20260818T081251PromotionsPromotionUsageOrderFk,
|
|
41
|
+
Migration20260912T094715PromotionsSalesChannelPromotions,
|
|
42
|
+
];
|
|
43
|
+
export { Migration20260505T074605PromotionsCriteria, Migration20260618T100727PromotionsEngine, Migration20260818T081251PromotionsPromotionUsageOrderFk, Migration20260912T094715PromotionsSalesChannelPromotions, };
|
|
44
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAE,0CAA0C,EAAE,MAAM,0CAA0C,CAAC;AACtG,OAAO,EAAE,wCAAwC,EAAE,MAAM,wCAAwC,CAAC;AAClG,OAAO,EAAE,uDAAuD,EAAE,MAAM,0DAA0D,CAAC;AACnI,OAAO,EAAE,wDAAwD,EAAE,MAAM,0DAA0D,CAAC;AAEpI,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,0CAA0C;IAC1C,wCAAwC;IACxC,uDAAuD;IACvD,wDAAwD;CACzD,CAAC;AAEF,OAAO,EACL,0CAA0C,EAC1C,wCAAwC,EACxC,uDAAuD,EACvD,wDAAwD,GACzD,CAAC"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `./ports` subpath — the port interfaces this module publishes, and
|
|
3
|
+
* **nothing that exists at runtime** (layout contract R8, D-169).
|
|
4
|
+
*
|
|
5
|
+
* `tsc` compiles this file to `export {};`, and that emitted empty module is the
|
|
6
|
+
* `default` condition's target. A `types`-only `exports` entry type-checks and
|
|
7
|
+
* then answers `ERR_PACKAGE_PATH_NOT_EXPORTED` to any consumer whose toolchain
|
|
8
|
+
* emits the import, which is every consumer that cannot prove the import is a
|
|
9
|
+
* type. The implementation stays in `../backend/services/promotion-service.ts`
|
|
10
|
+
* and is reached through the container name `promotionUsageFinalizer`, never
|
|
11
|
+
* through this subpath.
|
|
12
|
+
*
|
|
13
|
+
* No entity class leaves by this door either, type-only included: D-168 shut
|
|
14
|
+
* that door on `./backend` precisely so that a stranger's
|
|
15
|
+
* `import type { Promotion } from '<pkg>/backend'` is a compile error in the
|
|
16
|
+
* stranger's own tree, and erasure would make re-opening it free at runtime and
|
|
17
|
+
* permanent at compile time.
|
|
18
|
+
*
|
|
19
|
+
* `backend/test/unit/packages/module-package-ports-surface.test.ts` holds this
|
|
20
|
+
* file to all of that, over the real package.
|
|
21
|
+
*/
|
|
22
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
23
|
+
/**
|
|
24
|
+
* Who redeemed a promotion, as the usage row records it.
|
|
25
|
+
*
|
|
26
|
+
* It lives here rather than beside the implementation because
|
|
27
|
+
* {@link PromotionUsageFinalizer.finalizeUsage} names it: a published signature
|
|
28
|
+
* whose argument type is only reachable at a private path is a method a
|
|
29
|
+
* consumer cannot write a variable for. It carries no ORM type of its own and
|
|
30
|
+
* would be perfectly at home in `@endora-commerce/contracts` — it is here
|
|
31
|
+
* because the interface that names it cannot be, and splitting one signature
|
|
32
|
+
* across two packages buys nothing.
|
|
33
|
+
*/
|
|
34
|
+
export interface UsageContext {
|
|
35
|
+
organizationId: string | null;
|
|
36
|
+
customerAccountId: string | null;
|
|
37
|
+
customerGroupId: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* The channel the order was placed through. Non-nullable (D-48): the one
|
|
40
|
+
* caller is `placeOrder`, which stamps the same id onto the order, and
|
|
41
|
+
* `promotion_usages.sales_channel_id` is `uuid not null`. It used to be
|
|
42
|
+
* `string | null` with a `?? randomUUID()` at the insert — issue #85's shape,
|
|
43
|
+
* one table over: a fabricated id in a column the statistics aggregates
|
|
44
|
+
* group by, so a usage row could never be joined back to a channel.
|
|
45
|
+
*/
|
|
46
|
+
salesChannelId: string;
|
|
47
|
+
}
|
|
48
|
+
/** One promotion the placement applied, as the redemption row records it. */
|
|
49
|
+
export interface FinalizeAppliedPromotion {
|
|
50
|
+
promotionId: string;
|
|
51
|
+
couponId: string | null;
|
|
52
|
+
amount: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The co-transactional half of the promotion seam, typed by its owner (D-94.5).
|
|
56
|
+
*
|
|
57
|
+
* `orders` used to declare this method itself, on a `PromotionPort` interface
|
|
58
|
+
* it wrote in `order-service.ts` and satisfied structurally. That is the shape
|
|
59
|
+
* D-77 rejected: `lazyPort<T>` is an unchecked cast, so with `T` on the
|
|
60
|
+
* consumer's side **nothing verifies that the provider still satisfies it** —
|
|
61
|
+
* `PromotionService.finalizeUsage` could have changed its parameter shape and
|
|
62
|
+
* `orders` would still have compiled. The interface therefore belongs to the
|
|
63
|
+
* provider, and until this module became a package "the provider" meant a
|
|
64
|
+
* relative import into `promotions/services/`, which is a reach into internals
|
|
65
|
+
* the owner never offered. It is the same interface on a subpath its owner
|
|
66
|
+
* declared: contract surface (D-171), which is what retires the
|
|
67
|
+
* `permanent: true` ledger entry that stood for it.
|
|
68
|
+
*
|
|
69
|
+
* It stays **out of `@endora-commerce/contracts`** on purpose. The first
|
|
70
|
+
* parameter is the caller's MikroORM `EntityManager`, and FR-034 forbids a
|
|
71
|
+
* MikroORM type in the contracts package — `admin` and `storefront` both
|
|
72
|
+
* compile it, which is why it holds zero `@mikro-orm` imports. That is what
|
|
73
|
+
* qualifies this interface for `./ports` rather than for the contracts package:
|
|
74
|
+
* the test is not *"is this a real published port"* but *"does this signature
|
|
75
|
+
* stop the interface living in `packages/contracts`"* (D-171).
|
|
76
|
+
*
|
|
77
|
+
* The seam is not a defect in the design, it *is* the design:
|
|
78
|
+
* `promotion_usages_order_fk` (`promotion_usages.order_id` -> `orders.id`,
|
|
79
|
+
* `on delete restrict`) means the redemption row cannot exist before the order
|
|
80
|
+
* does, and the order does not commit until `placeOrder` returns. A port that
|
|
81
|
+
* opened its own transaction could not satisfy a foreign key against a row it
|
|
82
|
+
* cannot see, and a cap hit at the last moment could no longer roll the order
|
|
83
|
+
* back with it. A foreign key needs the **table** and never the class (D-169),
|
|
84
|
+
* so that constraint stands while `./backend` publishes no entity class by
|
|
85
|
+
* name.
|
|
86
|
+
*
|
|
87
|
+
* The read half is not here. `applyToCart` is `PromotionApplyPort` in
|
|
88
|
+
* `@endora-commerce/contracts`, which `carts` already resolves under the same
|
|
89
|
+
* container name; the consumer-declared duplicate of it was deleted with
|
|
90
|
+
* `PromotionPort`.
|
|
91
|
+
*/
|
|
92
|
+
export interface PromotionUsageFinalizer {
|
|
93
|
+
/**
|
|
94
|
+
* Finalize usage for a placed order. **Must** run on the placement
|
|
95
|
+
* `EntityManager`: counters are bumped with `update … where count < limit`,
|
|
96
|
+
* so two carts racing for the final use can never both succeed, and a cap
|
|
97
|
+
* hit here throws 409 so the transaction rolls back.
|
|
98
|
+
*/
|
|
99
|
+
finalizeUsage(em: EntityManager, input: {
|
|
100
|
+
orderId: string;
|
|
101
|
+
currency: string;
|
|
102
|
+
ctx: UsageContext;
|
|
103
|
+
applied: FinalizeAppliedPromotion[];
|
|
104
|
+
}): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ports/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,YAAY;IAC3B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B;;;;;;;OAOG;IACH,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,6EAA6E;AAC7E,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,aAAa,CACX,EAAE,EAAE,aAAa,EACjB,KAAK,EAAE;QACL,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,GAAG,EAAE,YAAY,CAAC;QAClB,OAAO,EAAE,wBAAwB,EAAE,CAAC;KACrC,GACA,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ports/index.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: promotions
|
|
3
|
+
description: Cart-level percentage / amount / free-delivery discounts with eligibility filters
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `promotions`
|
|
7
|
+
|
|
8
|
+
The promotions module is a configurable, rule-driven discount engine
|
|
9
|
+
built on the original cart-discount slice. A **Promotion**
|
|
10
|
+
pairs an eligibility **Rule** with an **Action**; the engine evaluates every
|
|
11
|
+
active promotion against a cart, applies the matching ones in priority order,
|
|
12
|
+
and surfaces the result in the cart and on the placed order.
|
|
13
|
+
|
|
14
|
+
## Concepts
|
|
15
|
+
|
|
16
|
+
- **Rule** — a typed AST (`all` | `condition` | `group`) over built-in cart
|
|
17
|
+
fields (cart total, payment method, delivery method, delivery country,
|
|
18
|
+
delivery postal code, organization, customer group, product category) and
|
|
19
|
+
promo-eligible product attributes. Conditions carry an operator
|
|
20
|
+
(`eq`/`neq`/`gt`/`gte`/`lt`/`lte`/`between`/`in`/`notIn`/`contains`/
|
|
21
|
+
`startsWith`) and values; groups combine children with `AND`/`OR` (max depth
|
|
22
|
+
5). A rule may be authored inline or referenced from the **named-rule**
|
|
23
|
+
library (`promotion_rules`).
|
|
24
|
+
- **Action** — one configured effect from the registry. Built-ins:
|
|
25
|
+
`free_delivery`, `percentage_off_cart`, `amount_off_cart`,
|
|
26
|
+
`buy_x_get_y_free` (cheapest/most-expensive target), `spend_x_percent_off`,
|
|
27
|
+
`spend_x_amount_off`, `every_nth_product_percent_off`, `buy_x_units_y_free`,
|
|
28
|
+
`buy_x_units_percent_off`, `buy_x_units_amount_off`. Other modules register
|
|
29
|
+
additional action types via `PromotionActionRegistry.register(...)`.
|
|
30
|
+
- **Priority & stacking** — eligible promotions apply in `priority` DESC order
|
|
31
|
+
(deterministic tie-break: `createdAt`, then `id`). A promotion flagged
|
|
32
|
+
`stopFurther` halts any lower-priority promotion. Discounts never push a
|
|
33
|
+
line, subtotal, delivery, or total below zero.
|
|
34
|
+
- **Coupons** — a promotion may carry coupons (`promotion_coupons`): a single
|
|
35
|
+
specified code or a generated batch (`coupon_batches`). A couponed promotion
|
|
36
|
+
applies only when a matching code is presented. The legacy `promotions.code`
|
|
37
|
+
column is still honored.
|
|
38
|
+
- **Usage limits** — optional global / per-organization / per-customer caps.
|
|
39
|
+
Coupon batches choose `per_coupon` vs `shared_batch` scoping for the global
|
|
40
|
+
pool. Usage is counted only on successful order placement.
|
|
41
|
+
- **Statistics** — `promotion_usages` records each finalized redemption with
|
|
42
|
+
denormalized dimensions, aggregated into totals + breakdowns by customer,
|
|
43
|
+
customer group, organization, and sales channel.
|
|
44
|
+
|
|
45
|
+
## Application flow
|
|
46
|
+
|
|
47
|
+
1. `PromotionService.applyToCart(snapshot)` loads active promotions, resolves
|
|
48
|
+
any presented coupon code, soft-excludes exhausted promotions, evaluates
|
|
49
|
+
each rule, runs the action through the registry against running totals, and
|
|
50
|
+
returns the adjusted totals plus a per-promotion breakdown.
|
|
51
|
+
2. The cart read path calls this on every read so the cart shows the real
|
|
52
|
+
discount amount and `appliedPromotions[]`.
|
|
53
|
+
3. On order placement, the order service recomputes the application, stamps
|
|
54
|
+
`order_applied_promotions` + `orders.discount_total`, and **finalizes
|
|
55
|
+
usage** inside the placement transaction.
|
|
56
|
+
|
|
57
|
+
## Usage finalization (race-safe)
|
|
58
|
+
|
|
59
|
+
`finalizeUsage` runs inside the order-placement transaction. Each applicable
|
|
60
|
+
scope counter is bumped with `UPDATE promotion_usage_counters SET count =
|
|
61
|
+
count + 1 WHERE (scope_type, scope_key) = … AND count < :limit`. Zero rows
|
|
62
|
+
affected means the cap was reached, so a `409 promotion_unavailable` is thrown
|
|
63
|
+
and the whole placement rolls back. Two carts racing for the final use can
|
|
64
|
+
never both succeed.
|
|
65
|
+
|
|
66
|
+
## Public surface (admin, gated by `promotions:read|write|delete`)
|
|
67
|
+
|
|
68
|
+
| Verb + Path | Purpose |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `GET/POST/PUT/DELETE /api/v1/admin/promotions[/:id]` | Promotion CRUD |
|
|
71
|
+
| `GET /api/v1/admin/promotions/action-types` | Action catalogue for the editor |
|
|
72
|
+
| `GET /api/v1/admin/promotions/rule-targets/attributes` | Promo-eligible attributes |
|
|
73
|
+
| `POST /api/v1/admin/promotions/preview` | Apply against a `CartSnapshot` |
|
|
74
|
+
| `GET/POST /api/v1/admin/promotions/:id/coupons` | Single-coupon management |
|
|
75
|
+
| `POST /api/v1/admin/promotions/:id/coupon-batches` | Bulk generator |
|
|
76
|
+
| `GET .../coupon-batches/:batchId/export` | CSV export of generated codes |
|
|
77
|
+
| `GET /api/v1/admin/promotions/:id/stats` | Usage statistics |
|
|
78
|
+
| `GET/POST/PUT/DELETE /api/v1/admin/promotion-rules[/:id]` | Named-rule library |
|
|
79
|
+
|
|
80
|
+
Coupon redemption on the storefront flows through the existing cart coupon
|
|
81
|
+
endpoints (`POST /api/v1/cart/coupon`), which resolve the code via the coupon
|
|
82
|
+
table or the legacy column.
|
|
83
|
+
|
|
84
|
+
## Permissions
|
|
85
|
+
|
|
86
|
+
- `promotions:read` — view promotions, rules, coupons, statistics.
|
|
87
|
+
- `promotions:write` — create + edit promotions and rules, manage coupons.
|
|
88
|
+
- `promotions:delete` — delete promotions and rules.
|
|
89
|
+
|
|
90
|
+
## Performance note
|
|
91
|
+
|
|
92
|
+
Cart pricing loads active promotions with a single indexed query plus a
|
|
93
|
+
coupon/counters lookup. At the platform's target scale this is sufficient; a
|
|
94
|
+
Redis per-channel candidate cache (invalidated via the module-lifecycle
|
|
95
|
+
pub/sub channel) is the documented next optimization if profiling shows the
|
|
96
|
+
per-request load becomes hot — deliberately deferred under YAGNI until then.
|
package/i18n/en.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"actions.openPromotions.label": "Promotions",
|
|
3
|
+
"actions.openPromotions.description": "Manage promotions, discounts, coupons, and rules",
|
|
4
|
+
"actions.newPromotion.label": "New promotion",
|
|
5
|
+
"actions.newPromotion.description": "Create a new promotion",
|
|
6
|
+
"list.title": "Promotions",
|
|
7
|
+
"list.new": "New promotion",
|
|
8
|
+
"fields.name": "Name",
|
|
9
|
+
"fields.description": "Description",
|
|
10
|
+
"fields.active": "Active",
|
|
11
|
+
"fields.priority": "Priority",
|
|
12
|
+
"fields.stopFurther": "Stop further promotions",
|
|
13
|
+
"fields.salesChannels": "Sales channels",
|
|
14
|
+
"fields.validFrom": "Start date",
|
|
15
|
+
"fields.validUntil": "End date",
|
|
16
|
+
"rule.title": "Rule",
|
|
17
|
+
"action.title": "Action",
|
|
18
|
+
"action.free_delivery": "Apply free delivery",
|
|
19
|
+
"action.percentage_off_cart": "X% off the whole cart",
|
|
20
|
+
"action.amount_off_cart": "Y off the whole cart",
|
|
21
|
+
"action.buy_x_get_y_free": "Buy X get Y free",
|
|
22
|
+
"action.spend_x_percent_off": "For every X spent, Y% off the cart",
|
|
23
|
+
"action.spend_x_amount_off": "For every X spent, Y off the cart",
|
|
24
|
+
"action.every_nth_product_percent_off": "Every Nth product Y% cheaper",
|
|
25
|
+
"action.buy_x_units_y_free": "Buy X units of a product, Y free",
|
|
26
|
+
"action.buy_x_units_percent_off": "Buy X units of a product, Y% off the cart",
|
|
27
|
+
"action.buy_x_units_amount_off": "Buy X units of a product, Y off the cart",
|
|
28
|
+
"limits.title": "Usage limits",
|
|
29
|
+
"limits.global": "Global limit",
|
|
30
|
+
"limits.perOrganization": "Per organization",
|
|
31
|
+
"limits.perCustomer": "Per customer",
|
|
32
|
+
"nav.promotions.label": "Promotions",
|
|
33
|
+
"nav.promotionRules.label": "Promotion rules"
|
|
34
|
+
}
|
package/i18n/pl.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"actions.openPromotions.label": "Promocje",
|
|
3
|
+
"actions.openPromotions.description": "Zarządzaj promocjami, rabatami, kuponami i regułami",
|
|
4
|
+
"actions.newPromotion.label": "Nowa promocja",
|
|
5
|
+
"actions.newPromotion.description": "Utwórz nową promocję",
|
|
6
|
+
"list.title": "Promocje",
|
|
7
|
+
"list.new": "Nowa promocja",
|
|
8
|
+
"fields.name": "Nazwa",
|
|
9
|
+
"fields.description": "Opis",
|
|
10
|
+
"fields.active": "Aktywna",
|
|
11
|
+
"fields.priority": "Priorytet",
|
|
12
|
+
"fields.stopFurther": "Zatrzymaj kolejne promocje",
|
|
13
|
+
"fields.salesChannels": "Kanały sprzedaży",
|
|
14
|
+
"fields.validFrom": "Data startu",
|
|
15
|
+
"fields.validUntil": "Data końca",
|
|
16
|
+
"rule.title": "Reguła",
|
|
17
|
+
"action.title": "Akcja",
|
|
18
|
+
"action.free_delivery": "Zaaplikuj darmową dostawę",
|
|
19
|
+
"action.percentage_off_cart": "Zniżka X% na cały koszyk",
|
|
20
|
+
"action.amount_off_cart": "Zniżka Y zł na cały koszyk",
|
|
21
|
+
"action.buy_x_get_y_free": "Kup X otrzymaj Y za darmo",
|
|
22
|
+
"action.spend_x_percent_off": "Za każde wydane X zniżka Y% na koszyk",
|
|
23
|
+
"action.spend_x_amount_off": "Za każde wydane X zniżka Y zł na koszyk",
|
|
24
|
+
"action.every_nth_product_percent_off": "Co X-ty produkt Y% taniej",
|
|
25
|
+
"action.buy_x_units_y_free": "Kup X sztuk produktu, Y gratis",
|
|
26
|
+
"action.buy_x_units_percent_off": "Kup X sztuk produktu, Y% zniżki na koszyk",
|
|
27
|
+
"action.buy_x_units_amount_off": "Kup X sztuk produktu, Y zł zniżki na koszyk",
|
|
28
|
+
"limits.title": "Limity użyć",
|
|
29
|
+
"limits.global": "Limit globalny",
|
|
30
|
+
"limits.perOrganization": "Na organizację",
|
|
31
|
+
"limits.perCustomer": "Na klienta",
|
|
32
|
+
"nav.promotions.label": "Promocje",
|
|
33
|
+
"nav.promotionRules.label": "Reguły promocji"
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@endora-commerce/mod-promotions",
|
|
3
|
+
"version": "0.100.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"description": "Promotion rules engine: rule builder, actions, coupons, limits, statistics.",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"endora": {
|
|
9
|
+
"type": "module",
|
|
10
|
+
"id": "promotions"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/endora-commerce/endora-commerce.git",
|
|
15
|
+
"directory": "packages/modules/promotions"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/manifest.d.ts",
|
|
23
|
+
"default": "./dist/manifest.js"
|
|
24
|
+
},
|
|
25
|
+
"./backend": {
|
|
26
|
+
"types": "./dist/backend/index.d.ts",
|
|
27
|
+
"default": "./dist/backend/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./migrations": {
|
|
30
|
+
"types": "./dist/migrations/index.d.ts",
|
|
31
|
+
"default": "./dist/migrations/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./ports": {
|
|
34
|
+
"types": "./dist/ports/index.d.ts",
|
|
35
|
+
"default": "./dist/ports/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./admin": {
|
|
38
|
+
"types": "./dist/admin/index.d.ts",
|
|
39
|
+
"default": "./dist/admin/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./tailwind.css": "./tailwind.css",
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"i18n",
|
|
47
|
+
"docs",
|
|
48
|
+
"tailwind.css"
|
|
49
|
+
],
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22.18.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@mikro-orm/core": "^6",
|
|
55
|
+
"@mikro-orm/migrations": "^6",
|
|
56
|
+
"@mikro-orm/postgresql": "^6",
|
|
57
|
+
"fastify": "^5",
|
|
58
|
+
"lucide-react": "^1",
|
|
59
|
+
"react": "^19",
|
|
60
|
+
"react-router-dom": "^7",
|
|
61
|
+
"@endora-commerce/admin-kit": "0.100.0",
|
|
62
|
+
"@endora-commerce/contracts": "0.100.0",
|
|
63
|
+
"@endora-commerce/platform": "0.100.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"@endora-commerce/admin-kit": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"lucide-react": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"react": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"react-router-dom": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@mikro-orm/core": "^6.6.13",
|
|
81
|
+
"@mikro-orm/migrations": "^6.6.13",
|
|
82
|
+
"@mikro-orm/postgresql": "^6.6.13",
|
|
83
|
+
"@types/node": "^22.9.0",
|
|
84
|
+
"@types/react": "^19.2.14",
|
|
85
|
+
"fastify": "^5.12.5",
|
|
86
|
+
"lucide-react": "^1.11.0",
|
|
87
|
+
"react": "^19.2.5",
|
|
88
|
+
"react-router-dom": "^7.18.2",
|
|
89
|
+
"typescript": "^5.9.3",
|
|
90
|
+
"vitest": "^4.1.11",
|
|
91
|
+
"@endora-commerce/admin-kit": "0.100.0",
|
|
92
|
+
"@endora-commerce/contracts": "0.100.0",
|
|
93
|
+
"@endora-commerce/platform": "0.100.0"
|
|
94
|
+
},
|
|
95
|
+
"scripts": {
|
|
96
|
+
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.ui.json",
|
|
97
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.ui.json --noEmit",
|
|
98
|
+
"lint": "eslint src",
|
|
99
|
+
"test": "vitest run"
|
|
100
|
+
}
|
|
101
|
+
}
|
package/tailwind.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* @endora-commerce/mod-promotions — AUTO-GENERATED by `pnpm --filter backend run manifests:generate`.
|
|
2
|
+
*
|
|
3
|
+
* The `@source` directives this package asks its host to scan
|
|
4
|
+
* (`specs/110-instance-repository/contracts/admin-stylesheet-composition.md` R1).
|
|
5
|
+
* They resolve relative to **this file**, so they hold wherever the package is
|
|
6
|
+
* installed — a workspace link here, `node_modules` in a client's instance.
|
|
7
|
+
*
|
|
8
|
+
* The `dist` line is what a published tarball ships and is what an instance
|
|
9
|
+
* scans; the `src` line is inert there and is what keeps `pnpm --filter admin
|
|
10
|
+
* run dev` reading source in this repository. Do not edit: run
|
|
11
|
+
* `pnpm --filter backend run manifests:generate`.
|
|
12
|
+
*/
|
|
13
|
+
@source "./dist/admin";
|
|
14
|
+
@source "./src/admin";
|