@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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/dist/admin/api/promotions-client.d.ts +98 -0
  4. package/dist/admin/api/promotions-client.d.ts.map +1 -0
  5. package/dist/admin/api/promotions-client.js +40 -0
  6. package/dist/admin/api/promotions-client.js.map +1 -0
  7. package/dist/admin/components/CouponGeneratorForm.d.ts +10 -0
  8. package/dist/admin/components/CouponGeneratorForm.d.ts.map +1 -0
  9. package/dist/admin/components/CouponGeneratorForm.js +56 -0
  10. package/dist/admin/components/CouponGeneratorForm.js.map +1 -0
  11. package/dist/admin/index.d.ts +43 -0
  12. package/dist/admin/index.d.ts.map +1 -0
  13. package/dist/admin/index.js +69 -0
  14. package/dist/admin/index.js.map +1 -0
  15. package/dist/admin/pages/PromotionEditPage.d.ts +9 -0
  16. package/dist/admin/pages/PromotionEditPage.d.ts.map +1 -0
  17. package/dist/admin/pages/PromotionEditPage.js +333 -0
  18. package/dist/admin/pages/PromotionEditPage.js.map +1 -0
  19. package/dist/admin/pages/PromotionRulesPage.d.ts +14 -0
  20. package/dist/admin/pages/PromotionRulesPage.d.ts.map +1 -0
  21. package/dist/admin/pages/PromotionRulesPage.js +86 -0
  22. package/dist/admin/pages/PromotionRulesPage.js.map +1 -0
  23. package/dist/admin/pages/PromotionStatsPage.d.ts +13 -0
  24. package/dist/admin/pages/PromotionStatsPage.d.ts.map +1 -0
  25. package/dist/admin/pages/PromotionStatsPage.js +44 -0
  26. package/dist/admin/pages/PromotionStatsPage.js.map +1 -0
  27. package/dist/admin/pages/PromotionsPage.d.ts +9 -0
  28. package/dist/admin/pages/PromotionsPage.d.ts.map +1 -0
  29. package/dist/admin/pages/PromotionsPage.js +79 -0
  30. package/dist/admin/pages/PromotionsPage.js.map +1 -0
  31. package/dist/backend/actions/amount-off-cart.d.ts +4 -0
  32. package/dist/backend/actions/amount-off-cart.d.ts.map +1 -0
  33. package/dist/backend/actions/amount-off-cart.js +16 -0
  34. package/dist/backend/actions/amount-off-cart.js.map +1 -0
  35. package/dist/backend/actions/buy-x-get-y-free.d.ts +8 -0
  36. package/dist/backend/actions/buy-x-get-y-free.d.ts.map +1 -0
  37. package/dist/backend/actions/buy-x-get-y-free.js +20 -0
  38. package/dist/backend/actions/buy-x-get-y-free.js.map +1 -0
  39. package/dist/backend/actions/buy-x-units-amount-off.d.ts +4 -0
  40. package/dist/backend/actions/buy-x-units-amount-off.d.ts.map +1 -0
  41. package/dist/backend/actions/buy-x-units-amount-off.js +19 -0
  42. package/dist/backend/actions/buy-x-units-amount-off.js.map +1 -0
  43. package/dist/backend/actions/buy-x-units-percent-off.d.ts +4 -0
  44. package/dist/backend/actions/buy-x-units-percent-off.d.ts.map +1 -0
  45. package/dist/backend/actions/buy-x-units-percent-off.js +15 -0
  46. package/dist/backend/actions/buy-x-units-percent-off.js.map +1 -0
  47. package/dist/backend/actions/buy-x-units-y-free.d.ts +7 -0
  48. package/dist/backend/actions/buy-x-units-y-free.d.ts.map +1 -0
  49. package/dist/backend/actions/buy-x-units-y-free.js +21 -0
  50. package/dist/backend/actions/buy-x-units-y-free.js.map +1 -0
  51. package/dist/backend/actions/every-nth-product-percent-off.d.ts +7 -0
  52. package/dist/backend/actions/every-nth-product-percent-off.d.ts.map +1 -0
  53. package/dist/backend/actions/every-nth-product-percent-off.js +17 -0
  54. package/dist/backend/actions/every-nth-product-percent-off.js.map +1 -0
  55. package/dist/backend/actions/free-delivery.d.ts +4 -0
  56. package/dist/backend/actions/free-delivery.d.ts.map +1 -0
  57. package/dist/backend/actions/free-delivery.js +11 -0
  58. package/dist/backend/actions/free-delivery.js.map +1 -0
  59. package/dist/backend/actions/percentage-off-cart.d.ts +4 -0
  60. package/dist/backend/actions/percentage-off-cart.d.ts.map +1 -0
  61. package/dist/backend/actions/percentage-off-cart.js +11 -0
  62. package/dist/backend/actions/percentage-off-cart.js.map +1 -0
  63. package/dist/backend/actions/spend-x-amount-off.d.ts +4 -0
  64. package/dist/backend/actions/spend-x-amount-off.d.ts.map +1 -0
  65. package/dist/backend/actions/spend-x-amount-off.js +17 -0
  66. package/dist/backend/actions/spend-x-amount-off.js.map +1 -0
  67. package/dist/backend/actions/spend-x-percent-off.d.ts +4 -0
  68. package/dist/backend/actions/spend-x-percent-off.d.ts.map +1 -0
  69. package/dist/backend/actions/spend-x-percent-off.js +18 -0
  70. package/dist/backend/actions/spend-x-percent-off.js.map +1 -0
  71. package/dist/backend/actions/types.d.ts +37 -0
  72. package/dist/backend/actions/types.d.ts.map +1 -0
  73. package/dist/backend/actions/types.js +2 -0
  74. package/dist/backend/actions/types.js.map +1 -0
  75. package/dist/backend/actions/util.d.ts +13 -0
  76. package/dist/backend/actions/util.d.ts.map +1 -0
  77. package/dist/backend/actions/util.js +18 -0
  78. package/dist/backend/actions/util.js.map +1 -0
  79. package/dist/backend/entities/coupon-batch.entity.d.ts +21 -0
  80. package/dist/backend/entities/coupon-batch.entity.d.ts.map +1 -0
  81. package/dist/backend/entities/coupon-batch.entity.js +78 -0
  82. package/dist/backend/entities/coupon-batch.entity.js.map +1 -0
  83. package/dist/backend/entities/promotion-coupon.entity.d.ts +17 -0
  84. package/dist/backend/entities/promotion-coupon.entity.d.ts.map +1 -0
  85. package/dist/backend/entities/promotion-coupon.entity.js +64 -0
  86. package/dist/backend/entities/promotion-coupon.entity.js.map +1 -0
  87. package/dist/backend/entities/promotion-rule.entity.d.ts +16 -0
  88. package/dist/backend/entities/promotion-rule.entity.d.ts.map +1 -0
  89. package/dist/backend/entities/promotion-rule.entity.js +57 -0
  90. package/dist/backend/entities/promotion-rule.entity.js.map +1 -0
  91. package/dist/backend/entities/promotion-usage-counter.entity.d.ts +15 -0
  92. package/dist/backend/entities/promotion-usage-counter.entity.d.ts.map +1 -0
  93. package/dist/backend/entities/promotion-usage-counter.entity.js +48 -0
  94. package/dist/backend/entities/promotion-usage-counter.entity.js.map +1 -0
  95. package/dist/backend/entities/promotion-usage.entity.d.ts +21 -0
  96. package/dist/backend/entities/promotion-usage.entity.d.ts.map +1 -0
  97. package/dist/backend/entities/promotion-usage.entity.js +89 -0
  98. package/dist/backend/entities/promotion-usage.entity.js.map +1 -0
  99. package/dist/backend/entities/promotion.entity.d.ts +63 -0
  100. package/dist/backend/entities/promotion.entity.d.ts.map +1 -0
  101. package/dist/backend/entities/promotion.entity.js +194 -0
  102. package/dist/backend/entities/promotion.entity.js.map +1 -0
  103. package/dist/backend/index.d.ts +124 -0
  104. package/dist/backend/index.d.ts.map +1 -0
  105. package/dist/backend/index.js +182 -0
  106. package/dist/backend/index.js.map +1 -0
  107. package/dist/backend/routes.d.ts +88 -0
  108. package/dist/backend/routes.d.ts.map +1 -0
  109. package/dist/backend/routes.js +256 -0
  110. package/dist/backend/routes.js.map +1 -0
  111. package/dist/backend/services/coupon-service.d.ts +40 -0
  112. package/dist/backend/services/coupon-service.d.ts.map +1 -0
  113. package/dist/backend/services/coupon-service.js +178 -0
  114. package/dist/backend/services/coupon-service.js.map +1 -0
  115. package/dist/backend/services/promotion-action-registry.d.ts +20 -0
  116. package/dist/backend/services/promotion-action-registry.d.ts.map +1 -0
  117. package/dist/backend/services/promotion-action-registry.js +57 -0
  118. package/dist/backend/services/promotion-action-registry.js.map +1 -0
  119. package/dist/backend/services/promotion-code-port.d.ts +24 -0
  120. package/dist/backend/services/promotion-code-port.d.ts.map +1 -0
  121. package/dist/backend/services/promotion-code-port.js +53 -0
  122. package/dist/backend/services/promotion-code-port.js.map +1 -0
  123. package/dist/backend/services/promotion-currency-reference.d.ts +13 -0
  124. package/dist/backend/services/promotion-currency-reference.d.ts.map +1 -0
  125. package/dist/backend/services/promotion-currency-reference.js +25 -0
  126. package/dist/backend/services/promotion-currency-reference.js.map +1 -0
  127. package/dist/backend/services/promotion-rule-evaluator.d.ts +32 -0
  128. package/dist/backend/services/promotion-rule-evaluator.d.ts.map +1 -0
  129. package/dist/backend/services/promotion-rule-evaluator.js +131 -0
  130. package/dist/backend/services/promotion-rule-evaluator.js.map +1 -0
  131. package/dist/backend/services/promotion-rule-service.d.ts +62 -0
  132. package/dist/backend/services/promotion-rule-service.d.ts.map +1 -0
  133. package/dist/backend/services/promotion-rule-service.js +95 -0
  134. package/dist/backend/services/promotion-rule-service.js.map +1 -0
  135. package/dist/backend/services/promotion-rule-store.d.ts +33 -0
  136. package/dist/backend/services/promotion-rule-store.d.ts.map +1 -0
  137. package/dist/backend/services/promotion-rule-store.js +88 -0
  138. package/dist/backend/services/promotion-rule-store.js.map +1 -0
  139. package/dist/backend/services/promotion-service.d.ts +312 -0
  140. package/dist/backend/services/promotion-service.d.ts.map +1 -0
  141. package/dist/backend/services/promotion-service.js +762 -0
  142. package/dist/backend/services/promotion-service.js.map +1 -0
  143. package/dist/backend/services/promotion-stats-service.d.ts +15 -0
  144. package/dist/backend/services/promotion-stats-service.d.ts.map +1 -0
  145. package/dist/backend/services/promotion-stats-service.js +66 -0
  146. package/dist/backend/services/promotion-stats-service.js.map +1 -0
  147. package/dist/backend/services/promotion-usage-service.d.ts +46 -0
  148. package/dist/backend/services/promotion-usage-service.d.ts.map +1 -0
  149. package/dist/backend/services/promotion-usage-service.js +168 -0
  150. package/dist/backend/services/promotion-usage-service.js.map +1 -0
  151. package/dist/manifest.d.ts +172 -0
  152. package/dist/manifest.d.ts.map +1 -0
  153. package/dist/manifest.js +140 -0
  154. package/dist/manifest.js.map +1 -0
  155. package/dist/migrations/20260505T074605_promotions_criteria.d.ts +19 -0
  156. package/dist/migrations/20260505T074605_promotions_criteria.d.ts.map +1 -0
  157. package/dist/migrations/20260505T074605_promotions_criteria.js +23 -0
  158. package/dist/migrations/20260505T074605_promotions_criteria.js.map +1 -0
  159. package/dist/migrations/20260618T100727_promotions_engine.d.ts +19 -0
  160. package/dist/migrations/20260618T100727_promotions_engine.d.ts.map +1 -0
  161. package/dist/migrations/20260618T100727_promotions_engine.js +148 -0
  162. package/dist/migrations/20260618T100727_promotions_engine.js.map +1 -0
  163. package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.d.ts +42 -0
  164. package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.d.ts.map +1 -0
  165. package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.js +76 -0
  166. package/dist/migrations/20260818T081251_promotions_promotion_usage_order_fk.js.map +1 -0
  167. package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.d.ts +27 -0
  168. package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.d.ts.map +1 -0
  169. package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.js +44 -0
  170. package/dist/migrations/20260912T094715_promotions_sales_channel_promotions.js.map +1 -0
  171. package/dist/migrations/index.d.ts +39 -0
  172. package/dist/migrations/index.d.ts.map +1 -0
  173. package/dist/migrations/index.js +44 -0
  174. package/dist/migrations/index.js.map +1 -0
  175. package/dist/ports/index.d.ts +106 -0
  176. package/dist/ports/index.d.ts.map +1 -0
  177. package/dist/ports/index.js +2 -0
  178. package/dist/ports/index.js.map +1 -0
  179. package/docs/promotions.md +96 -0
  180. package/i18n/en.json +34 -0
  181. package/i18n/pl.json +34 -0
  182. package/package.json +101 -0
  183. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -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";