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