@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,762 @@
1
+ import { DictionaryReferenceError, ERROR_CODES, dispatchValidatorMode, } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ import { recordAuditFromContext } from '@endora-commerce/platform/commands';
4
+ import { Promotion } from '../entities/promotion.entity.js';
5
+ import { PromotionRuleEntity } from '../entities/promotion-rule.entity.js';
6
+ import { PromotionCoupon } from '../entities/promotion-coupon.entity.js';
7
+ import { lineMatchesAllCriteria, } from './promotion-rule-service.js';
8
+ import { evaluatePromotionRule, } from './promotion-rule-evaluator.js';
9
+ import { createPromotionActionRegistry, } from './promotion-action-registry.js';
10
+ import { PromotionUsageService, } from './promotion-usage-service.js';
11
+ /**
12
+ * PromotionService (T132 / FR-052).
13
+ *
14
+ * `applyToCart(snapshot)` walks every active, in-window promotion and
15
+ * applies the ones the cart is eligible for, returning the adjusted
16
+ * subtotal / discountTotal / deliveryTotal / total + a per-promotion
17
+ * audit list.
18
+ *
19
+ * Application order: code-presented promotions first (so a coupon
20
+ * always trumps an automatic), then automatic ones in `value` desc.
21
+ * Discounts never push the subtotal below zero or delivery below zero.
22
+ */
23
+ export class PromotionService {
24
+ emFactory;
25
+ catalogPort;
26
+ productReadPort;
27
+ salesChannelMembership;
28
+ dictionaryValidator;
29
+ resolveOrganizationStatus;
30
+ auditLog;
31
+ auditLogger;
32
+ actionRegistry;
33
+ constructor(emFactory,
34
+ /**
35
+ * Feature 012 / US8 — `catalogPromoAttributePort`, owned by `catalog`: the
36
+ * attribute metadata a rule criterion is validated and evaluated against.
37
+ *
38
+ * **Required** (issue #164). It used to be optional "so legacy tests /
39
+ * composition setups that don't wire this stay green", and production
40
+ * always passed it — an optional dependency nobody omits is a lie in the
41
+ * type: it tells every reader that absence is supported, and it makes the
42
+ * absent branch untested by construction. Both consequences were real.
43
+ * `validateCriteria` opened by returning early, so a composition without
44
+ * the port **accepted a promotion naming an attribute that does not
45
+ * exist**; and no test covered that, because the ten call sites that
46
+ * omitted the port were the ones that never exercised attributes.
47
+ *
48
+ * There is no degrade left to express. Absence is not a state the platform
49
+ * can be in: the container resolves this port for every composition, and
50
+ * `catalog` being switched off makes the call **throw**
51
+ * `ModuleDisabledError` at the resolution seam — which is the explicit
52
+ * answer, and it is the owner's to give. What remains is
53
+ * `getAttributeWithOptions` answering `null`, already explicit in its
54
+ * return type and meaning what it says: no such attribute.
55
+ */
56
+ catalogPort,
57
+ /**
58
+ * Feature 075 Phase C — `catalogProductReadPort`, owned by `catalog`. It
59
+ * replaced an `em.find(Product, …)` this module wrote against `catalog`'s
60
+ * table to hydrate a cart line's attribute values, and it is required for
61
+ * the reason above: a composition that could not read products evaluated
62
+ * every attribute criterion against a line with no attribute values, which
63
+ * is not a degrade an operator would recognise as one.
64
+ */
65
+ productReadPort,
66
+ /**
67
+ * Feature 005 / T027b — the sanctioned bridge accessor for
68
+ * `sales_channel_promotions`: it auto-binds a newly-created Promotion to
69
+ * the system-default channel, and it answers which promotions a channel
70
+ * holds when a cart is evaluated.
71
+ *
72
+ * **Required** (issue #251), and of the four this is the one whose absent
73
+ * form was a Principle XII hole rather than a quiet one. Omitting it did
74
+ * two things at once: a new promotion bound to **no** channel, and
75
+ * `applyToCart` skipped channel filtering altogether — so a promotion an
76
+ * operator scoped to one channel applied in every one of them. Both roots
77
+ * passed the service, so nothing was live; every service built by
78
+ * `test/helpers/promotion-service.ts` was not, which is ten suites
79
+ * asserting against a promotion engine with no channel scope.
80
+ *
81
+ * There is no degrade to express in a return type. `sales_channels` is
82
+ * `nonDeactivatable` and `salesChannelMembershipPort` is a
83
+ * platform-owned container name besides, so absence is not a state a
84
+ * composition can reach.
85
+ */
86
+ salesChannelMembership,
87
+ /**
88
+ * Feature 037 — the dictionary reference validator, owned by
89
+ * `dictionaries`: it is what makes a promotion's `currency` a code the
90
+ * platform actually knows.
91
+ *
92
+ * **Required** (issue #251). `validateCurrency` opened with "no validator,
93
+ * no validation" and returned, so a composition without it stored a
94
+ * promotion denominated in a currency that does not exist — and the
95
+ * failure surfaced far downstream, at the cart, as a discount in a
96
+ * currency nothing could price. `dictionaries` is a declared dependency
97
+ * and is `nonDeactivatable`, so absence is not a state a composition can
98
+ * reach; an unknown or withdrawn code is already explicit, as the 409 this
99
+ * method raises.
100
+ */
101
+ dictionaryValidator,
102
+ /**
103
+ * Feature 026 US5 — the Organization status gate: an org-targeted
104
+ * promotion only applies while its Organization is `active`.
105
+ *
106
+ * **Required** (issue #251), and the one `backend.ts` already named in its
107
+ * own comment as *"a gate whose absent form is open"*. Omitted, the check
108
+ * was skipped entirely, so a `blocked` (formerly `suspended`) or
109
+ * `pending_verification` customer kept collecting its negotiated
110
+ * discounts — the exact thing US5 exists to stop.
111
+ *
112
+ * The degrade that **is** real stays in the return type, the way
113
+ * `allowedIdsFor(): Promise<string[] | null>` does: `null` means the
114
+ * Organization could not be read (missing or soft-deleted), and a status
115
+ * that is not `active` — `null` included — skips the org-targeted
116
+ * promotion. That is a closed answer, and it is the only absence this
117
+ * argument has.
118
+ */
119
+ resolveOrganizationStatus,
120
+ /**
121
+ * Feature 054 — co-transactional audit sink (`audit_log_entries`),
122
+ * Principle XIII.
123
+ *
124
+ * **Required** (issue #251). Without it every promotion create, update and
125
+ * delete committed with no audit row, which is not a degrade — it is the
126
+ * write happening unrecorded. `auditLogService` is a platform-owned
127
+ * container name that every composition supplies, so there was never a
128
+ * composition the absent branch described.
129
+ */
130
+ auditLog,
131
+ /** Feature 012 / US8 — audit sink for FR-039 skip-on-toggle events. */
132
+ auditLogger = {
133
+ info: (message, fields) => console.warn(`[audit] ${message}`, fields ?? {}),
134
+ },
135
+ /** Feature 045 — pluggable action catalogue. Defaults to the built-ins. */
136
+ actionRegistry = createPromotionActionRegistry()) {
137
+ this.emFactory = emFactory;
138
+ this.catalogPort = catalogPort;
139
+ this.productReadPort = productReadPort;
140
+ this.salesChannelMembership = salesChannelMembership;
141
+ this.dictionaryValidator = dictionaryValidator;
142
+ this.resolveOrganizationStatus = resolveOrganizationStatus;
143
+ this.auditLog = auditLog;
144
+ this.auditLogger = auditLogger;
145
+ this.actionRegistry = actionRegistry;
146
+ this.usageService = new PromotionUsageService(emFactory);
147
+ }
148
+ /** Feature 054 — co-transactional promotion audit on `em` (actor from context). */
149
+ #audit(em, action, objectId, stateBefore, stateAfter) {
150
+ recordAuditFromContext(this.auditLog, em, {
151
+ action,
152
+ objectType: 'promotion',
153
+ objectId,
154
+ stateBefore,
155
+ stateAfter,
156
+ });
157
+ }
158
+ usageService;
159
+ /**
160
+ * Feature 045 (US5) — finalize usage for a placed order. MUST run inside the
161
+ * order-placement transaction (the passed `em` is that tx). Throws 409 if a
162
+ * usage cap is hit at the last moment so the transaction rolls back.
163
+ */
164
+ async finalizeUsage(em, input) {
165
+ await this.usageService.finalize(em, input);
166
+ }
167
+ /** Expose the action catalogue for the admin `action-types` endpoint. */
168
+ listActionTypes() {
169
+ return this.actionRegistry.list().map((d) => ({ type: d.type, labelKey: d.labelKey }));
170
+ }
171
+ async list() {
172
+ return this.emFactory().find(Promotion, {}, { orderBy: { name: 'asc' } });
173
+ }
174
+ async getById(id) {
175
+ const row = await this.emFactory().findOne(Promotion, { id });
176
+ if (!row)
177
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Promotion ${id} not found.`);
178
+ return row;
179
+ }
180
+ async upsert(input) {
181
+ const em = this.emFactory();
182
+ const existing = input.code
183
+ ? await em.findOne(Promotion, { code: input.code })
184
+ : null;
185
+ await this.validateUpsertInput(em, input, existing);
186
+ const data = buildPromotionData(input);
187
+ if (existing) {
188
+ Object.assign(existing, data);
189
+ this.#audit(em, 'promotion.update', existing.id, null, { name: existing.name, code: existing.code });
190
+ await em.flush();
191
+ return existing;
192
+ }
193
+ // `data` is built from a partial input; `name` is always present per the
194
+ // schema, but the conditional spreads widen the inferred type.
195
+ const row = em.create(Promotion, data);
196
+ em.persist(row);
197
+ this.#audit(em, 'promotion.create', row.id, null, { name: row.name, code: row.code });
198
+ await em.flush();
199
+ await this.salesChannelMembership.bindToDefaultIfEmpty('promotion', row.id);
200
+ return row;
201
+ }
202
+ /** Feature 045 — update an existing promotion by id (admin edit path). */
203
+ async updateById(id, input) {
204
+ const em = this.emFactory();
205
+ const existing = await em.findOne(Promotion, { id });
206
+ if (!existing)
207
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Promotion ${id} not found.`);
208
+ await this.validateUpsertInput(em, input, existing);
209
+ Object.assign(existing, buildPromotionData(input));
210
+ this.#audit(em, 'promotion.update', existing.id, null, { name: existing.name, code: existing.code });
211
+ await em.flush();
212
+ return existing;
213
+ }
214
+ async validateUpsertInput(em, input, existing) {
215
+ if (input.criteria) {
216
+ await this.validateCriteria(input.criteria);
217
+ }
218
+ if (input.action && !this.actionRegistry.has(input.action.type)) {
219
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `unknown_promotion_action: ${input.action.type}`);
220
+ }
221
+ if (input.ruleId) {
222
+ const ruleRow = await em.findOne(PromotionRuleEntity, { id: input.ruleId });
223
+ if (!ruleRow) {
224
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `rule_not_found: ${input.ruleId}`);
225
+ }
226
+ }
227
+ if (input.currency != null) {
228
+ await this.validateCurrency(input.currency, existing ? dispatchValidatorMode(existing.currency, input.currency) : 'create-or-change');
229
+ }
230
+ }
231
+ async remove(id) {
232
+ const em = this.emFactory();
233
+ const row = await em.findOne(Promotion, { id });
234
+ if (!row)
235
+ return;
236
+ this.#audit(em, 'promotion.delete', row.id, { name: row.name, code: row.code }, null);
237
+ await em.removeAndFlush(row);
238
+ }
239
+ async validateCurrency(currency, mode) {
240
+ try {
241
+ await this.dictionaryValidator.validateCurrencyCode(currency, mode);
242
+ }
243
+ catch (err) {
244
+ if (err instanceof DictionaryReferenceError) {
245
+ throw new HttpError(409, err.code, err.code === 'DICTIONARY_ENTRY_INACTIVE'
246
+ ? `Currency code ${err.entryCode} is no longer available for promotions.`
247
+ : `Currency code ${err.entryCode} is not recognised.`, [{ path: 'currency', issue: err.code }]);
248
+ }
249
+ throw err;
250
+ }
251
+ }
252
+ async applyToCart(snapshot) {
253
+ const em = this.emFactory();
254
+ const now = new Date();
255
+ const subtotal = round2(snapshot.lines.reduce((acc, line) => acc + line.unitPrice.amount * line.quantity, 0));
256
+ const all = await em.find(Promotion, { isActive: true });
257
+ // Feature 045 (US3) — resolve a presented code through the coupon table
258
+ // (legacy `promotions.code` gating is preserved below for back-compat).
259
+ let couponMatch = null;
260
+ const couponedPromotionIds = new Set();
261
+ if (all.length > 0) {
262
+ const coupons = await em.find(PromotionCoupon, {
263
+ promotionId: { $in: all.map((p) => p.id) },
264
+ isActive: true,
265
+ });
266
+ for (const c of coupons)
267
+ couponedPromotionIds.add(c.promotionId);
268
+ if (snapshot.promotionCode) {
269
+ const match = coupons.find((c) => c.code === snapshot.promotionCode);
270
+ if (match)
271
+ couponMatch = { promotionId: match.promotionId, couponId: match.id };
272
+ }
273
+ }
274
+ // Feature 026 US5 — when org-targeted promotions are present and the
275
+ // snapshot carries an organizationId, resolve that org's status once.
276
+ // Org-targeted promotions only apply if the org is `active`; `null` (org
277
+ // missing or soft-deleted) skips them too. The resolver is required since
278
+ // issue #251 — "no resolver wired, no gate" used to be a branch, and it
279
+ // was the branch every helper-built service took.
280
+ let orgStatusCheckResult = 'allow';
281
+ if (snapshot.organizationId) {
282
+ const hasOrgTargeted = all.some((p) => p.organizationId === snapshot.organizationId);
283
+ if (hasOrgTargeted) {
284
+ const status = await this.resolveOrganizationStatus(snapshot.organizationId);
285
+ if (status !== 'active') {
286
+ orgStatusCheckResult = 'skip-org-targeted';
287
+ }
288
+ }
289
+ }
290
+ // Feature 052 (US3) — sales-channel gate (FR-004 / FR-005). Resolve the set
291
+ // of promotion ids bound to the cart's resolved channel and reject any
292
+ // promotion whose `sales_channel_promotions` binding excludes it. The cart's
293
+ // channel is `snapshot.salesChannelId`, which the cart→snapshot mappers always
294
+ // populate explicitly (a uuid, or `null` when the cart resolved to no channel):
295
+ // - a uuid → keep only promotions bound to that channel;
296
+ // - `null` → the cart resolved to no channel → nothing matches (fail closed);
297
+ // - absent → a legacy caller that does not participate in channel scoping →
298
+ // the gate is skipped (neutrality; every real caller sends the field).
299
+ // The bridge is read only through SalesChannelMembershipPort (Principle
300
+ // XII's accessor clause, enforced by `check:module-boundary`'s `sql`
301
+ // predicate since D-87). The accessor is required since issue #251: it used
302
+ // to be optional, and "not wired ⇒ no channel filtering" is the shape
303
+ // Principle XII exists to forbid. This is an interim predicate subsumed by
304
+ // the future unified channel resolver (spec 03).
305
+ let channelPromotionIds = null;
306
+ if (all.length > 0 && snapshot.salesChannelId !== undefined) {
307
+ if (snapshot.salesChannelId) {
308
+ const ids = new Set();
309
+ const pageSize = 500;
310
+ let page = 0;
311
+ for (;;) {
312
+ const { entityIds, total } = await this.salesChannelMembership.listEntityIdsForChannel(snapshot.salesChannelId, 'promotion', page, pageSize);
313
+ for (const id of entityIds)
314
+ ids.add(id);
315
+ if (entityIds.length === 0 || ids.size >= total)
316
+ break;
317
+ page += 1;
318
+ }
319
+ channelPromotionIds = ids;
320
+ }
321
+ else {
322
+ // Null / unresolved channel → nothing matches (fail closed, FR-005).
323
+ channelPromotionIds = new Set();
324
+ }
325
+ }
326
+ let eligible = all.filter((p) => {
327
+ if (channelPromotionIds && !channelPromotionIds.has(p.id))
328
+ return false;
329
+ if (p.validFrom && now < p.validFrom)
330
+ return false;
331
+ if (p.validUntil && now > p.validUntil)
332
+ return false;
333
+ if (p.minCartSubtotal != null &&
334
+ Number(p.minCartSubtotal) > subtotal) {
335
+ return false;
336
+ }
337
+ if (p.organizationId && p.organizationId !== snapshot.organizationId)
338
+ return false;
339
+ // Org-targeted promotions only fire for an active Organization.
340
+ if (p.organizationId &&
341
+ orgStatusCheckResult === 'skip-org-targeted') {
342
+ return false;
343
+ }
344
+ if (p.customerGroupId && p.customerGroupId !== snapshot.customerGroupId)
345
+ return false;
346
+ // Legacy `promotions.code` gating.
347
+ if (p.code) {
348
+ if (!snapshot.promotionCode)
349
+ return false;
350
+ if (snapshot.promotionCode !== p.code)
351
+ return false;
352
+ }
353
+ // Feature 045 (US3) — coupon-table gating: a promotion carrying coupons
354
+ // applies only when the presented code resolves to one of its coupons.
355
+ if (couponedPromotionIds.has(p.id) && couponMatch?.promotionId !== p.id)
356
+ return false;
357
+ return true;
358
+ });
359
+ // Feature 045 (US5) — soft-exclude promotions whose limits are already met
360
+ // (best-effort; the atomic gate at placement is authoritative).
361
+ const exhausted = await this.usageService.filterExhausted(eligible, {
362
+ organizationId: snapshot.organizationId,
363
+ customerAccountId: snapshot.customerAccountId ?? null,
364
+ });
365
+ if (exhausted.size > 0)
366
+ eligible = eligible.filter((p) => !exhausted.has(p.id));
367
+ // Feature 045 — apply in priority order (DESC) with deterministic
368
+ // tie-break (createdAt ASC, then id). Coupon-presented promotions keep
369
+ // their precedence within equal priority.
370
+ eligible.sort((a, b) => {
371
+ if (a.priority !== b.priority)
372
+ return b.priority - a.priority;
373
+ if (a.code && !b.code)
374
+ return -1;
375
+ if (!a.code && b.code)
376
+ return 1;
377
+ const t = a.createdAt.getTime() - b.createdAt.getTime();
378
+ if (t !== 0)
379
+ return t;
380
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
381
+ });
382
+ // Feature 012 / US8 + 045 — pre-build the per-key attribute lookup for
383
+ // every attribute referenced by a promotion's criteria OR its rule.
384
+ const referencedKeys = new Set();
385
+ for (const p of eligible) {
386
+ for (const c of p.criteria) {
387
+ if (c.type === 'attribute')
388
+ referencedKeys.add(c.attributeKey);
389
+ }
390
+ }
391
+ // Feature 045 — pre-load named rules referenced by eligible promotions.
392
+ const ruleIds = [...new Set(eligible.map((p) => p.ruleId).filter((id) => !!id))];
393
+ const rulesById = new Map();
394
+ if (ruleIds.length > 0) {
395
+ const rows = await em.find(PromotionRuleEntity, { id: { $in: ruleIds } });
396
+ for (const r of rows)
397
+ rulesById.set(r.id, r.definition);
398
+ }
399
+ for (const p of eligible) {
400
+ if (!p.actionType)
401
+ continue;
402
+ const rule = resolveRule(p, rulesById);
403
+ for (const key of extractRuleAttributeKeys(rule))
404
+ referencedKeys.add(key);
405
+ }
406
+ const attributeLookup = await this.buildAttributeLookup([...referencedKeys]);
407
+ // Hydrate per-line `attributeValues` for any referenced key.
408
+ const hydratedSnapshot = referencedKeys.size === 0
409
+ ? snapshot
410
+ : await this.hydrateLineAttributeValues(snapshot, [...referencedKeys]);
411
+ const ruleCtx = buildRuleContext(hydratedSnapshot, subtotal, attributeLookup, (info) => this.auditLogger.info('promotion_rule_attribute_skipped', info));
412
+ let workingSubtotal = subtotal;
413
+ let workingDelivery = snapshot.deliveryTotal;
414
+ let discountTotal = 0;
415
+ const applied = [];
416
+ for (const promotion of eligible) {
417
+ // Feature 045 — action-based promotion: evaluate the typed rule, then
418
+ // run the configured action through the registry against running totals.
419
+ if (promotion.actionType) {
420
+ const rule = resolveRule(promotion, rulesById);
421
+ if (!evaluatePromotionRule(rule, ruleCtx))
422
+ continue;
423
+ const action = joinAction(promotion);
424
+ if (!action)
425
+ continue;
426
+ const applyCtx = {
427
+ lines: hydratedSnapshot.lines,
428
+ subtotal: workingSubtotal,
429
+ deliveryTotal: workingDelivery,
430
+ currency: snapshot.currency,
431
+ };
432
+ const result = this.actionRegistry.apply(action, applyCtx);
433
+ const subDelta = round2(Math.max(0, Math.min(result.discountSubtotalDelta, workingSubtotal)));
434
+ const delDelta = round2(Math.max(0, Math.min(result.discountDeliveryDelta, workingDelivery)));
435
+ const amount = round2(subDelta + delDelta);
436
+ if (amount <= 0)
437
+ continue;
438
+ workingSubtotal = round2(workingSubtotal - subDelta);
439
+ workingDelivery = round2(workingDelivery - delDelta);
440
+ discountTotal = round2(discountTotal + amount);
441
+ applied.push({
442
+ promotionId: promotion.id,
443
+ kind: null,
444
+ actionType: promotion.actionType,
445
+ couponId: couponMatch?.promotionId === promotion.id ? couponMatch.couponId : null,
446
+ amount,
447
+ });
448
+ if (promotion.stopFurther)
449
+ break;
450
+ continue;
451
+ }
452
+ // Legacy kind/value promotion.
453
+ const lineBase = this.computeLineBase(hydratedSnapshot, promotion, attributeLookup);
454
+ if (lineBase <= 0 && promotion.kind !== 'free_delivery')
455
+ continue;
456
+ let amount = 0;
457
+ if (promotion.kind === 'percentage_off') {
458
+ amount = round2((lineBase * Number(promotion.value)) / 100);
459
+ }
460
+ else if (promotion.kind === 'amount_off') {
461
+ if (promotion.currency && promotion.currency !== snapshot.currency)
462
+ continue;
463
+ amount = round2(Math.min(Number(promotion.value), lineBase));
464
+ }
465
+ else if (promotion.kind === 'free_delivery') {
466
+ amount = round2(workingDelivery);
467
+ workingDelivery = 0;
468
+ }
469
+ if (amount <= 0)
470
+ continue;
471
+ if (promotion.kind !== 'free_delivery') {
472
+ workingSubtotal = Math.max(0, round2(workingSubtotal - amount));
473
+ }
474
+ discountTotal = round2(discountTotal + amount);
475
+ applied.push({
476
+ promotionId: promotion.id,
477
+ kind: promotion.kind ?? null,
478
+ actionType: null,
479
+ couponId: couponMatch?.promotionId === promotion.id ? couponMatch.couponId : null,
480
+ amount,
481
+ });
482
+ if (promotion.stopFurther)
483
+ break;
484
+ }
485
+ const total = round2(workingSubtotal + workingDelivery);
486
+ return {
487
+ subtotal,
488
+ discountTotal,
489
+ deliveryTotal: workingDelivery,
490
+ total,
491
+ appliedPromotions: applied,
492
+ };
493
+ }
494
+ /**
495
+ * Feature 012 / US8 — validate a criteria array against the catalog
496
+ * port. Throws `HttpError(400, ...)` for the documented error codes
497
+ * (`attribute_not_found`, `attribute_not_promo_eligible`,
498
+ * `invalid_criterion_op`, `invalid_criterion_values`,
499
+ * `invalid_option_value`).
500
+ */
501
+ async validateCriteria(criteria) {
502
+ for (const c of criteria) {
503
+ if (c.type !== 'attribute')
504
+ continue;
505
+ const meta = await this.catalogPort.getAttributeWithOptions(c.attributeKey);
506
+ if (!meta) {
507
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `attribute_not_found: ${c.attributeKey}`);
508
+ }
509
+ if (!meta.isPromoRule) {
510
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `attribute_not_promo_eligible: ${c.attributeKey}`);
511
+ }
512
+ const allowed = allowedOpsFor(meta.valueType);
513
+ if (!allowed.includes(c.op)) {
514
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `invalid_criterion_op: ${c.op} not in [${allowed.join(', ')}] for ${meta.valueType}`);
515
+ }
516
+ validateValuesShape(c.op, c.values, meta.valueType);
517
+ // Per-option-value membership for select-style attributes.
518
+ if (meta.valueType === 'select' || meta.valueType === 'enum' || meta.valueType === 'multiselect') {
519
+ const allowedValues = new Set(meta.options.map((o) => o.value));
520
+ for (const v of c.values) {
521
+ if (!allowedValues.has(String(v))) {
522
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `invalid_option_value: ${String(v)}`);
523
+ }
524
+ }
525
+ }
526
+ }
527
+ }
528
+ async buildAttributeLookup(keys) {
529
+ const map = new Map();
530
+ if (keys.length === 0) {
531
+ return { get: (k) => map.get(k) ?? null };
532
+ }
533
+ for (const key of keys) {
534
+ const meta = await this.catalogPort.getAttributeWithOptions(key);
535
+ if (meta) {
536
+ map.set(key, {
537
+ id: meta.id,
538
+ key: meta.key,
539
+ valueType: meta.valueType,
540
+ isPromoRule: meta.isPromoRule,
541
+ options: meta.options.map((o) => ({ value: o.value })),
542
+ });
543
+ }
544
+ }
545
+ return { get: (k) => map.get(k) ?? null };
546
+ }
547
+ async hydrateLineAttributeValues(snapshot, referencedKeys) {
548
+ const productIds = [
549
+ ...new Set(snapshot.lines
550
+ .filter((l) => !l.attributeValues)
551
+ .map((l) => l.productId)),
552
+ ];
553
+ if (productIds.length === 0)
554
+ return snapshot;
555
+ const products = await this.productReadPort.findByIds(productIds);
556
+ const valuesByProductId = new Map();
557
+ for (const p of products) {
558
+ const values = p.attributeValues ?? {};
559
+ const subset = {};
560
+ for (const k of referencedKeys) {
561
+ if (k in values)
562
+ subset[k] = values[k];
563
+ }
564
+ valuesByProductId.set(p.id, subset);
565
+ }
566
+ return {
567
+ ...snapshot,
568
+ lines: snapshot.lines.map((l) => l.attributeValues
569
+ ? l
570
+ : { ...l, attributeValues: valuesByProductId.get(l.productId) ?? {} }),
571
+ };
572
+ }
573
+ computeLineBase(snapshot, p, attributeLookup) {
574
+ // free_delivery doesn't read line totals.
575
+ if (p.kind === 'free_delivery')
576
+ return snapshot.deliveryTotal;
577
+ const ctx = {
578
+ attributeLookup,
579
+ promotionId: p.id,
580
+ onSkip: ({ promotionId, criterionAttributeKey, reason }) => {
581
+ this.auditLogger.info('promotion_criterion_skipped', {
582
+ promotionId,
583
+ criterionAttributeKey,
584
+ reason,
585
+ });
586
+ },
587
+ };
588
+ return snapshot.lines.reduce((acc, line) => {
589
+ if (p.productId && line.productId !== p.productId)
590
+ return acc;
591
+ if (p.categoryId && !line.categoryIds.includes(p.categoryId))
592
+ return acc;
593
+ if (p.criteria.length > 0 && !lineMatchesAllCriteria(p.criteria, line, ctx))
594
+ return acc;
595
+ return acc + line.unitPrice.amount * line.quantity;
596
+ }, 0);
597
+ }
598
+ }
599
+ function allowedOpsFor(valueType) {
600
+ switch (valueType) {
601
+ case 'string':
602
+ return ['equals', 'in'];
603
+ case 'select':
604
+ case 'enum':
605
+ return ['equals', 'in'];
606
+ case 'multiselect':
607
+ return ['in'];
608
+ case 'number':
609
+ case 'price':
610
+ return ['equals', 'range'];
611
+ case 'boolean':
612
+ return ['equals'];
613
+ case 'date':
614
+ return ['equals', 'range'];
615
+ default:
616
+ return [];
617
+ }
618
+ }
619
+ function validateValuesShape(op, values, valueType) {
620
+ if (op === 'equals' && values.length !== 1) {
621
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'invalid_criterion_values: equals expects a single-element values array');
622
+ }
623
+ if (op === 'range') {
624
+ if (values.length !== 2) {
625
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'invalid_criterion_values: range expects exactly [min, max]');
626
+ }
627
+ const [min, max] = values;
628
+ if (valueType === 'date') {
629
+ if (!Number.isFinite(Date.parse(String(min))) || !Number.isFinite(Date.parse(String(max)))) {
630
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'invalid_criterion_values: range requires ISO date-time bounds');
631
+ }
632
+ }
633
+ else {
634
+ if (!Number.isFinite(Number(min)) || !Number.isFinite(Number(max))) {
635
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'invalid_criterion_values: range requires numeric bounds');
636
+ }
637
+ }
638
+ }
639
+ if (op === 'in' && values.length === 0) {
640
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'invalid_criterion_values: in expects a non-empty values array');
641
+ }
642
+ if (valueType === 'boolean' && op === 'equals') {
643
+ if (typeof values[0] !== 'boolean') {
644
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'invalid_criterion_values: boolean equals expects [true|false]');
645
+ }
646
+ }
647
+ }
648
+ function round2(value) {
649
+ return Math.round(value * 100) / 100;
650
+ }
651
+ /** Map an upsert input into the entity-assignable data object. */
652
+ function buildPromotionData(input) {
653
+ const { actionType, actionConfig } = splitAction(input.action);
654
+ return {
655
+ name: input.name,
656
+ ...(input.kind !== undefined ? { kind: input.kind } : {}),
657
+ ...(input.value !== undefined ? { value: String(input.value) } : {}),
658
+ ...(input.code !== undefined ? { code: input.code } : {}),
659
+ ...(input.currency !== undefined ? { currency: input.currency } : {}),
660
+ ...(input.minCartSubtotal !== undefined && input.minCartSubtotal !== null
661
+ ? { minCartSubtotal: String(input.minCartSubtotal) }
662
+ : {}),
663
+ ...(input.validFrom !== undefined
664
+ ? { validFrom: input.validFrom ? new Date(input.validFrom) : null }
665
+ : {}),
666
+ ...(input.validUntil !== undefined
667
+ ? { validUntil: input.validUntil ? new Date(input.validUntil) : null }
668
+ : {}),
669
+ ...(input.organizationId !== undefined ? { organizationId: input.organizationId } : {}),
670
+ ...(input.customerGroupId !== undefined ? { customerGroupId: input.customerGroupId } : {}),
671
+ ...(input.categoryId !== undefined ? { categoryId: input.categoryId } : {}),
672
+ ...(input.productId !== undefined ? { productId: input.productId } : {}),
673
+ ...(input.criteria !== undefined ? { criteria: input.criteria } : {}),
674
+ ...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
675
+ ...(input.description !== undefined ? { description: input.description } : {}),
676
+ ...(input.priority !== undefined ? { priority: input.priority } : {}),
677
+ ...(input.stopFurther !== undefined ? { stopFurther: input.stopFurther } : {}),
678
+ ...(input.action !== undefined ? { actionType, actionConfig } : {}),
679
+ // A rule source set clears the other (mutually exclusive).
680
+ ...(input.ruleId !== undefined ? { ruleId: input.ruleId, ruleDefinition: null } : {}),
681
+ ...(input.rule !== undefined ? { ruleDefinition: input.rule, ruleId: null } : {}),
682
+ ...(input.usageLimitGlobal !== undefined ? { usageLimitGlobal: input.usageLimitGlobal } : {}),
683
+ ...(input.usageLimitPerOrganization !== undefined
684
+ ? { usageLimitPerOrganization: input.usageLimitPerOrganization }
685
+ : {}),
686
+ ...(input.usageLimitPerCustomer !== undefined
687
+ ? { usageLimitPerCustomer: input.usageLimitPerCustomer }
688
+ : {}),
689
+ };
690
+ }
691
+ /** Split a `PromotionAction` union into the stored `(type, config)` pair. */
692
+ function splitAction(action) {
693
+ if (!action)
694
+ return { actionType: null, actionConfig: {} };
695
+ const { type, ...config } = action;
696
+ return { actionType: type, actionConfig: config };
697
+ }
698
+ /** Reconstruct a `PromotionAction` from the stored `(type, config)` pair. */
699
+ function joinAction(promotion) {
700
+ if (!promotion.actionType)
701
+ return null;
702
+ return { type: promotion.actionType, ...promotion.actionConfig };
703
+ }
704
+ /** Resolve a promotion's effective rule (inline, named, or match-all). */
705
+ function resolveRule(promotion, rulesById) {
706
+ if (promotion.ruleDefinition)
707
+ return promotion.ruleDefinition;
708
+ if (promotion.ruleId)
709
+ return rulesById.get(promotion.ruleId) ?? { kind: 'all' };
710
+ return { kind: 'all' };
711
+ }
712
+ /** Collect attribute keys referenced anywhere in a rule tree. */
713
+ function extractRuleAttributeKeys(rule) {
714
+ const keys = [];
715
+ const walk = (node) => {
716
+ if (node.kind === 'group') {
717
+ node.children.forEach(walk);
718
+ }
719
+ else if (node.kind === 'condition' && node.field.kind === 'attribute') {
720
+ keys.push(node.field.attributeKey);
721
+ }
722
+ };
723
+ walk(rule);
724
+ return keys;
725
+ }
726
+ function coerceRuleValue(v) {
727
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
728
+ return v;
729
+ return String(v);
730
+ }
731
+ /** Build the cart-level rule-evaluation context from a hydrated snapshot. */
732
+ function buildRuleContext(snapshot, subtotal, attributeLookup, onSkip) {
733
+ const attributeValues = {};
734
+ for (const line of snapshot.lines) {
735
+ const av = line.attributeValues ?? {};
736
+ for (const [k, raw] of Object.entries(av)) {
737
+ const bucket = (attributeValues[k] ??= []);
738
+ if (Array.isArray(raw)) {
739
+ for (const item of raw)
740
+ bucket.push(coerceRuleValue(item));
741
+ }
742
+ else if (raw != null) {
743
+ bucket.push(coerceRuleValue(raw));
744
+ }
745
+ }
746
+ }
747
+ const categoryIds = [...new Set(snapshot.lines.flatMap((l) => l.categoryIds))];
748
+ return {
749
+ cartTotal: subtotal,
750
+ paymentMethodCode: snapshot.paymentMethodCode ?? null,
751
+ deliveryMethodCode: snapshot.deliveryMethodCode ?? null,
752
+ deliveryCountry: snapshot.deliveryCountry ?? null,
753
+ deliveryPostalCode: snapshot.deliveryPostalCode ?? null,
754
+ organizationId: snapshot.organizationId,
755
+ customerGroupId: snapshot.customerGroupId,
756
+ categoryIds,
757
+ attributeValues,
758
+ isPromoEligibleAttribute: (key) => attributeLookup.get(key)?.isPromoRule ?? false,
759
+ onSkip,
760
+ };
761
+ }
762
+ //# sourceMappingURL=promotion-service.js.map