@openlup/core 0.0.0 → 0.7.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 (192) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/CODE_OF_CONDUCT.md +39 -0
  3. package/CONTRIBUTING.md +34 -0
  4. package/LICENSE +204 -0
  5. package/MAINTAINERS.md +33 -0
  6. package/README.md +85 -3
  7. package/SECURITY.md +29 -0
  8. package/dist/bundle/contracts.d.ts +9 -0
  9. package/dist/bundle/contracts.js +1 -0
  10. package/dist/bundle/ports.d.ts +41 -0
  11. package/dist/bundle/ports.js +1 -0
  12. package/dist/catalog/identifiers.d.ts +31 -0
  13. package/dist/catalog/identifiers.js +46 -0
  14. package/dist/catalog/index.d.ts +2 -0
  15. package/dist/catalog/index.js +2 -0
  16. package/dist/checkout/idempotency.d.ts +29 -0
  17. package/dist/checkout/idempotency.js +37 -0
  18. package/dist/checkout/index.d.ts +2 -0
  19. package/dist/checkout/index.js +2 -0
  20. package/dist/company-identity/contracts.d.ts +220 -0
  21. package/dist/company-identity/contracts.js +167 -0
  22. package/dist/company-identity/index.d.ts +6 -0
  23. package/dist/company-identity/index.js +2 -0
  24. package/dist/company-identity/ports.d.ts +13 -0
  25. package/dist/company-identity/ports.js +1 -0
  26. package/dist/fulfillment/facts.d.ts +103 -0
  27. package/dist/fulfillment/facts.js +195 -0
  28. package/dist/fulfillment/index.d.ts +4 -0
  29. package/dist/fulfillment/index.js +2 -0
  30. package/dist/inventory/atp.d.ts +172 -0
  31. package/dist/inventory/atp.js +211 -0
  32. package/dist/inventory/index.d.ts +2 -0
  33. package/dist/inventory/index.js +2 -0
  34. package/dist/marketing/research/contracts.d.ts +75 -0
  35. package/dist/marketing/research/contracts.js +60 -0
  36. package/dist/marketing/research/index.d.ts +4 -0
  37. package/dist/marketing/research/index.js +4 -0
  38. package/dist/marketing/research/ports.d.ts +15 -0
  39. package/dist/marketing/research/ports.js +1 -0
  40. package/dist/partners/index.d.ts +4 -0
  41. package/dist/partners/index.js +4 -0
  42. package/dist/partners/ports.d.ts +12 -0
  43. package/dist/partners/ports.js +1 -0
  44. package/dist/partners/status.d.ts +48 -0
  45. package/dist/partners/status.js +35 -0
  46. package/dist/payment/executionBaseContracts.d.ts +81 -0
  47. package/dist/payment/executionBaseContracts.js +8 -0
  48. package/dist/payment/index.d.ts +36 -0
  49. package/dist/payment/index.js +18 -0
  50. package/dist/payment/methodLifecycle.d.ts +91 -0
  51. package/dist/payment/methodLifecycle.js +76 -0
  52. package/dist/payment/paymentControlTypes.d.ts +135 -0
  53. package/dist/payment/paymentControlTypes.js +110 -0
  54. package/dist/payment/paymentFailureTaxonomy.d.ts +80 -0
  55. package/dist/payment/paymentFailureTaxonomy.js +155 -0
  56. package/dist/payment/paymentFailureTaxonomyContracts.d.ts +106 -0
  57. package/dist/payment/paymentFailureTaxonomyContracts.js +177 -0
  58. package/dist/payment/paymentRecovery.d.ts +26 -0
  59. package/dist/payment/paymentRecovery.js +73 -0
  60. package/dist/payment/paymentRecoveryContracts.d.ts +53 -0
  61. package/dist/payment/paymentRecoveryContracts.js +10 -0
  62. package/dist/payment/paymentStateMachine.d.ts +28 -0
  63. package/dist/payment/paymentStateMachine.js +184 -0
  64. package/dist/payment/providerAttemptIdempotency.d.ts +23 -0
  65. package/dist/payment/providerAttemptIdempotency.js +31 -0
  66. package/dist/payment/providerCapability.d.ts +148 -0
  67. package/dist/payment/providerCapability.js +13 -0
  68. package/dist/platform-runtime/contracts.d.ts +9 -0
  69. package/dist/platform-runtime/contracts.js +13 -0
  70. package/dist/platform-runtime/index.d.ts +10 -0
  71. package/dist/platform-runtime/index.js +4 -0
  72. package/dist/platform-runtime/platformKernel.d.ts +44 -0
  73. package/dist/platform-runtime/platformKernel.js +39 -0
  74. package/dist/platform-runtime/ports.d.ts +100 -0
  75. package/dist/platform-runtime/ports.js +1 -0
  76. package/dist/pricing/index.d.ts +8 -0
  77. package/dist/pricing/index.js +8 -0
  78. package/dist/pricing/ports.d.ts +22 -0
  79. package/dist/pricing/ports.js +7 -0
  80. package/dist/pricing/pricingBreakdown.d.ts +86 -0
  81. package/dist/pricing/pricingBreakdown.js +155 -0
  82. package/dist/pricing/targetPriceAllocation.d.ts +97 -0
  83. package/dist/pricing/targetPriceAllocation.js +202 -0
  84. package/dist/pricing/types.d.ts +32 -0
  85. package/dist/pricing/types.js +4 -0
  86. package/dist/promo/adjustmentEngine.d.ts +4 -0
  87. package/dist/promo/adjustmentEngine.js +194 -0
  88. package/dist/promo/adjustmentTypes.d.ts +103 -0
  89. package/dist/promo/adjustmentTypes.js +13 -0
  90. package/dist/promo/index.d.ts +8 -0
  91. package/dist/promo/index.js +8 -0
  92. package/dist/promo/promoEvaluator.d.ts +25 -0
  93. package/dist/promo/promoEvaluator.js +135 -0
  94. package/dist/promo/types.d.ts +86 -0
  95. package/dist/promo/types.js +12 -0
  96. package/dist/risk/core/evaluator.d.ts +3 -0
  97. package/dist/risk/core/evaluator.js +32 -0
  98. package/dist/risk/core/rules.d.ts +3 -0
  99. package/dist/risk/core/rules.js +118 -0
  100. package/dist/risk/core/types.d.ts +65 -0
  101. package/dist/risk/core/types.js +1 -0
  102. package/dist/risk/index.d.ts +8 -0
  103. package/dist/risk/index.js +8 -0
  104. package/dist/risk/types.d.ts +32 -0
  105. package/dist/risk/types.js +43 -0
  106. package/dist/shipping/index.d.ts +4 -0
  107. package/dist/shipping/index.js +4 -0
  108. package/dist/shipping/shippingResolver.d.ts +26 -0
  109. package/dist/shipping/shippingResolver.js +88 -0
  110. package/dist/shipping/types.d.ts +45 -0
  111. package/dist/shipping/types.js +4 -0
  112. package/dist/subscription/cycleHardening.d.ts +95 -0
  113. package/dist/subscription/cycleHardening.js +118 -0
  114. package/dist/subscription/index.d.ts +14 -0
  115. package/dist/subscription/index.js +14 -0
  116. package/dist/subscription/subscriptionEngineCore.d.ts +13 -0
  117. package/dist/subscription/subscriptionEngineCore.js +145 -0
  118. package/dist/subscription/subscriptionEngineLifecycle.d.ts +150 -0
  119. package/dist/subscription/subscriptionEngineLifecycle.js +433 -0
  120. package/dist/subscription/subscriptionEnginePayment.d.ts +25 -0
  121. package/dist/subscription/subscriptionEnginePayment.js +127 -0
  122. package/dist/subscription/subscriptionEngineTypes.d.ts +94 -0
  123. package/dist/subscription/subscriptionEngineTypes.js +4 -0
  124. package/dist/subscription/subscriptionEngineUtils.d.ts +32 -0
  125. package/dist/subscription/subscriptionEngineUtils.js +74 -0
  126. package/dist/subscription/types.d.ts +58 -0
  127. package/dist/subscription/types.js +47 -0
  128. package/dist/testing.d.ts +48 -0
  129. package/dist/testing.js +105 -0
  130. package/package.json +215 -4
  131. package/release-gates.json +232 -0
  132. package/src/bundle/contracts.ts +15 -0
  133. package/src/bundle/ports.ts +32 -0
  134. package/src/catalog/identifiers.ts +50 -0
  135. package/src/catalog/index.ts +2 -0
  136. package/src/checkout/idempotency.ts +49 -0
  137. package/src/checkout/index.ts +2 -0
  138. package/src/company-identity/contracts.ts +208 -0
  139. package/src/company-identity/index.ts +28 -0
  140. package/src/company-identity/ports.ts +19 -0
  141. package/src/fulfillment/facts.ts +300 -0
  142. package/src/fulfillment/index.ts +34 -0
  143. package/src/inventory/atp.ts +249 -0
  144. package/src/inventory/index.ts +2 -0
  145. package/src/marketing/research/contracts.ts +91 -0
  146. package/src/marketing/research/index.ts +4 -0
  147. package/src/marketing/research/ports.ts +26 -0
  148. package/src/partners/index.ts +4 -0
  149. package/src/partners/ports.ts +18 -0
  150. package/src/partners/status.ts +49 -0
  151. package/src/payment/executionBaseContracts.ts +93 -0
  152. package/src/payment/index.ts +128 -0
  153. package/src/payment/methodLifecycle.ts +130 -0
  154. package/src/payment/paymentControlTypes.ts +197 -0
  155. package/src/payment/paymentFailureTaxonomy.ts +201 -0
  156. package/src/payment/paymentFailureTaxonomyContracts.ts +228 -0
  157. package/src/payment/paymentRecovery.ts +95 -0
  158. package/src/payment/paymentRecoveryContracts.ts +61 -0
  159. package/src/payment/paymentStateMachine.ts +263 -0
  160. package/src/payment/providerAttemptIdempotency.ts +57 -0
  161. package/src/payment/providerCapability.ts +172 -0
  162. package/src/platform-runtime/contracts.ts +33 -0
  163. package/src/platform-runtime/index.ts +36 -0
  164. package/src/platform-runtime/platformKernel.ts +86 -0
  165. package/src/platform-runtime/ports.ts +88 -0
  166. package/src/pricing/index.ts +8 -0
  167. package/src/pricing/ports.ts +27 -0
  168. package/src/pricing/pricingBreakdown.ts +243 -0
  169. package/src/pricing/targetPriceAllocation.ts +293 -0
  170. package/src/pricing/types.ts +36 -0
  171. package/src/promo/adjustmentEngine.ts +253 -0
  172. package/src/promo/adjustmentTypes.ts +94 -0
  173. package/src/promo/index.ts +8 -0
  174. package/src/promo/promoEvaluator.ts +137 -0
  175. package/src/promo/types.ts +97 -0
  176. package/src/risk/core/evaluator.ts +41 -0
  177. package/src/risk/core/rules.ts +128 -0
  178. package/src/risk/core/types.ts +73 -0
  179. package/src/risk/index.ts +8 -0
  180. package/src/risk/types.ts +60 -0
  181. package/src/shipping/index.ts +4 -0
  182. package/src/shipping/shippingResolver.ts +101 -0
  183. package/src/shipping/types.ts +49 -0
  184. package/src/subscription/cycleHardening.ts +174 -0
  185. package/src/subscription/index.ts +14 -0
  186. package/src/subscription/subscriptionEngineCore.ts +181 -0
  187. package/src/subscription/subscriptionEngineLifecycle.ts +574 -0
  188. package/src/subscription/subscriptionEnginePayment.ts +179 -0
  189. package/src/subscription/subscriptionEngineTypes.ts +114 -0
  190. package/src/subscription/subscriptionEngineUtils.ts +110 -0
  191. package/src/subscription/types.ts +104 -0
  192. package/src/testing.ts +169 -0
@@ -0,0 +1,253 @@
1
+ import {
2
+ PROMOTION_ADJUSTMENT_KINDS,
3
+ PROMOTION_ADJUSTMENT_LANES,
4
+ PROMOTION_ENGINE_V2,
5
+ PROMOTION_PURCHASE_SCOPES,
6
+ type AppliedAdjustment,
7
+ type PromotionAdjustmentCandidate,
8
+ type PromotionAdjustmentContext,
9
+ type PromotionAdjustmentLane,
10
+ type PromotionAdjustmentResult,
11
+ type PromotionCodeRejection,
12
+ } from "./adjustmentTypes.js";
13
+
14
+ interface ScoredCandidate {
15
+ candidate: PromotionAdjustmentCandidate;
16
+ amountOffMinor: number;
17
+ beforeMinor: number;
18
+ afterMinor: number;
19
+ floorApplied: boolean;
20
+ }
21
+
22
+ /** Select at most one product and one shipping adjustment. */
23
+ /** @beta */
24
+ export function evaluatePromotionAdjustmentsV2(
25
+ context: PromotionAdjustmentContext,
26
+ candidates: readonly PromotionAdjustmentCandidate[],
27
+ ): PromotionAdjustmentResult {
28
+ const minimumProductPayableMinor = validateContext(context);
29
+ candidates.forEach(validateCandidate);
30
+ validateCodeIdentity(candidates);
31
+
32
+ const applicable = candidates.filter((candidate) => candidate.scopes.includes(context.purchaseScope));
33
+ const productWinner = pickWinner(
34
+ applicable.filter((candidate) => candidate.lane === "product"),
35
+ context,
36
+ "product",
37
+ minimumProductPayableMinor,
38
+ );
39
+ const shippingWinner = pickWinner(
40
+ applicable.filter((candidate) => candidate.lane === "shipping"),
41
+ context,
42
+ "shipping",
43
+ minimumProductPayableMinor,
44
+ );
45
+ const winners = [productWinner, shippingWinner].filter(
46
+ (winner): winner is ScoredCandidate => winner !== null,
47
+ );
48
+ const productPayableMinor = productWinner?.afterMinor ?? context.currentProductMinor;
49
+ const shippingPayableMinor = shippingWinner?.afterMinor ?? context.shippingMinor;
50
+
51
+ return {
52
+ engineVersion: PROMOTION_ENGINE_V2,
53
+ adjustments: winners.map(toAppliedAdjustment),
54
+ rejectedCodes: rejectedCodes(candidates, applicable, winners),
55
+ productPayableMinor,
56
+ shippingPayableMinor,
57
+ effectiveProductDiscountBps: Number(
58
+ (BigInt(context.referenceProductMinor - productPayableMinor) * 10_000n) /
59
+ BigInt(context.referenceProductMinor),
60
+ ),
61
+ };
62
+ }
63
+
64
+ function validateContext(context: PromotionAdjustmentContext): number {
65
+ const floor = context.minimumProductPayableMinor;
66
+ if (!PROMOTION_PURCHASE_SCOPES.includes(context.purchaseScope)) {
67
+ throw new RangeError("purchaseScope is invalid");
68
+ }
69
+ assertMinor("referenceProductMinor", context.referenceProductMinor, 1);
70
+ assertMinor("minimumProductPayableMinor", floor, 1);
71
+ assertMinor("currentProductMinor", context.currentProductMinor, floor);
72
+ assertMinor("shippingMinor", context.shippingMinor, 0);
73
+ if (context.currentProductMinor > context.referenceProductMinor) {
74
+ throw new RangeError("currentProductMinor cannot exceed referenceProductMinor");
75
+ }
76
+ return floor;
77
+ }
78
+
79
+ function validateCandidate(candidate: PromotionAdjustmentCandidate): void {
80
+ const runtimeLane = candidate.lane as string;
81
+ const runtimeKind = candidate.kind as string;
82
+ if (typeof candidate.promotionId !== "string" || typeof candidate.name !== "string" ||
83
+ !candidate.promotionId.trim() || !candidate.name.trim()) {
84
+ throw new RangeError("promotionId and name are required");
85
+ }
86
+ if (candidate.source !== "automatic" && candidate.source !== "code") {
87
+ throw new RangeError("candidate source is invalid");
88
+ }
89
+ if (!PROMOTION_ADJUSTMENT_LANES.includes(runtimeLane as PromotionAdjustmentLane)) {
90
+ throw new RangeError("candidate lane is invalid");
91
+ }
92
+ if (!PROMOTION_ADJUSTMENT_KINDS.includes(runtimeKind as (typeof PROMOTION_ADJUSTMENT_KINDS)[number])) {
93
+ throw new RangeError("candidate kind is invalid");
94
+ }
95
+ if (!Array.isArray(candidate.scopes) || candidate.scopes.length === 0 ||
96
+ candidate.scopes.some((scope) => !PROMOTION_PURCHASE_SCOPES.includes(scope)) ||
97
+ new Set(candidate.scopes).size !== candidate.scopes.length) {
98
+ throw new RangeError("candidate scopes are invalid");
99
+ }
100
+ if (candidate.source === "code" && (typeof candidate.codeId !== "string" || !candidate.codeId.trim())) {
101
+ throw new RangeError("code candidates require codeId");
102
+ }
103
+ if (candidate.source === "automatic" && (candidate.codeId !== undefined || candidate.code !== undefined)) {
104
+ throw new RangeError("automatic candidates cannot carry code identity");
105
+ }
106
+ if (candidate.kind === "target_percentage" || candidate.kind === "percentage") {
107
+ assertMinor("valueBps", candidate.valueBps, 1);
108
+ if ((candidate.valueBps ?? 0) >= 10_000) throw new RangeError("valueBps must be below 10000");
109
+ if (candidate.valueMinor !== undefined) throw new RangeError("percentage candidates cannot set valueMinor");
110
+ } else if (candidate.kind === "fixed_amount") {
111
+ assertMinor("valueMinor", candidate.valueMinor, 1);
112
+ if (candidate.valueBps !== undefined) throw new RangeError("fixed candidates cannot set valueBps");
113
+ } else if (candidate.valueBps !== undefined || candidate.valueMinor !== undefined) {
114
+ throw new RangeError("free_shipping cannot set a value");
115
+ }
116
+ if (runtimeLane === "product" && runtimeKind !== "target_percentage" && runtimeKind !== "fixed_amount") {
117
+ throw new RangeError("product candidate kind is invalid");
118
+ }
119
+ if (runtimeLane === "shipping" && runtimeKind === "target_percentage") {
120
+ throw new RangeError("shipping candidate kind is invalid");
121
+ }
122
+ }
123
+
124
+ function validateCodeIdentity(candidates: readonly PromotionAdjustmentCandidate[]): void {
125
+ const rawById = new Map<string, string | undefined>();
126
+ for (const candidate of candidates) {
127
+ if (candidate.source !== "code" || !candidate.codeId) continue;
128
+ if (rawById.has(candidate.codeId) && rawById.get(candidate.codeId) !== candidate.code) {
129
+ throw new RangeError("codeId candidates must use one code value");
130
+ }
131
+ rawById.set(candidate.codeId, candidate.code);
132
+ }
133
+ }
134
+
135
+ function pickWinner(
136
+ candidates: PromotionAdjustmentCandidate[],
137
+ context: PromotionAdjustmentContext,
138
+ lane: PromotionAdjustmentLane,
139
+ floor: number,
140
+ ): ScoredCandidate | null {
141
+ const scored = candidates
142
+ .map((candidate) => scoreCandidate(candidate, context, lane, floor))
143
+ .filter((candidate) => candidate.amountOffMinor > 0)
144
+ .sort(compareScored);
145
+ return scored[0] ?? null;
146
+ }
147
+
148
+ function scoreCandidate(
149
+ candidate: PromotionAdjustmentCandidate,
150
+ context: PromotionAdjustmentContext,
151
+ lane: PromotionAdjustmentLane,
152
+ floor: number,
153
+ ): ScoredCandidate {
154
+ const beforeMinor = lane === "product" ? context.currentProductMinor : context.shippingMinor;
155
+ let requestedOffMinor = 0;
156
+ if (candidate.kind === "free_shipping") {
157
+ requestedOffMinor = context.shippingMinor;
158
+ } else if (candidate.kind === "fixed_amount") {
159
+ requestedOffMinor = candidate.valueMinor ?? 0;
160
+ } else if (lane === "shipping") {
161
+ requestedOffMinor = multiplyDivideFloor(context.shippingMinor, candidate.valueBps ?? 0);
162
+ } else {
163
+ const targetPayableMinor = multiplyDivideCeil(
164
+ context.referenceProductMinor,
165
+ 10_000 - (candidate.valueBps ?? 0),
166
+ );
167
+ requestedOffMinor = Math.max(0, context.currentProductMinor - targetPayableMinor);
168
+ }
169
+ const minimumPayable = lane === "product" ? floor : 0;
170
+ const amountOffMinor = Math.min(requestedOffMinor, Math.max(0, beforeMinor - minimumPayable));
171
+ return {
172
+ candidate,
173
+ amountOffMinor,
174
+ beforeMinor,
175
+ afterMinor: beforeMinor - amountOffMinor,
176
+ floorApplied: lane === "product" && requestedOffMinor > amountOffMinor,
177
+ };
178
+ }
179
+
180
+ function compareScored(left: ScoredCandidate, right: ScoredCandidate): number {
181
+ if (left.amountOffMinor !== right.amountOffMinor) return right.amountOffMinor - left.amountOffMinor;
182
+ if (left.candidate.source !== right.candidate.source) {
183
+ return left.candidate.source === "automatic" ? -1 : 1;
184
+ }
185
+ return compareCodeUnits(left.candidate.promotionId, right.candidate.promotionId);
186
+ }
187
+
188
+ function toAppliedAdjustment(winner: ScoredCandidate): AppliedAdjustment {
189
+ const candidate = winner.candidate;
190
+ return {
191
+ engineVersion: PROMOTION_ENGINE_V2,
192
+ promotionId: candidate.promotionId,
193
+ ...(candidate.codeId ? { codeId: candidate.codeId } : {}),
194
+ ...(candidate.code ? { code: candidate.code } : {}),
195
+ name: candidate.name,
196
+ source: candidate.source,
197
+ lane: candidate.lane,
198
+ kind: candidate.kind,
199
+ amountOffMinor: winner.amountOffMinor,
200
+ beforeMinor: winner.beforeMinor,
201
+ afterMinor: winner.afterMinor,
202
+ floorApplied: winner.floorApplied,
203
+ ...(candidate.kind === "target_percentage"
204
+ ? { targetEffectiveDiscountBps: candidate.valueBps }
205
+ : {}),
206
+ };
207
+ }
208
+
209
+ function rejectedCodes(
210
+ all: readonly PromotionAdjustmentCandidate[],
211
+ applicable: readonly PromotionAdjustmentCandidate[],
212
+ winners: readonly ScoredCandidate[],
213
+ ): PromotionCodeRejection[] {
214
+ const applicableIds = new Set(applicable.map((candidate) => candidate.codeId));
215
+ const winnerIds = new Set(
216
+ winners
217
+ .filter((winner) => winner.candidate.source === "code")
218
+ .map((winner) => winner.candidate.codeId),
219
+ );
220
+ const codes = new Map<string, PromotionCodeRejection>();
221
+ for (const candidate of all) {
222
+ if (candidate.source !== "code" || !candidate.codeId || winnerIds.has(candidate.codeId)) continue;
223
+ const reason = applicableIds.has(candidate.codeId) ? "better_price_exists" : "scope_not_applicable";
224
+ const previous = codes.get(candidate.codeId);
225
+ if (!previous || previous.reason === "scope_not_applicable") {
226
+ codes.set(candidate.codeId, {
227
+ codeId: candidate.codeId,
228
+ ...(candidate.code ? { code: candidate.code } : {}),
229
+ reason,
230
+ });
231
+ }
232
+ }
233
+ return [...codes.values()].sort((left, right) => compareCodeUnits(left.codeId ?? "", right.codeId ?? ""));
234
+ }
235
+
236
+ function assertMinor(name: string, value: number | undefined, minimum: number): void {
237
+ if (!Number.isSafeInteger(value) || (value ?? 0) < minimum) {
238
+ throw new RangeError(`${name} must be a safe integer >= ${minimum}`);
239
+ }
240
+ }
241
+
242
+ function multiplyDivideFloor(value: number, multiplier: number): number {
243
+ return Number((BigInt(value) * BigInt(multiplier)) / 10_000n);
244
+ }
245
+
246
+ function multiplyDivideCeil(value: number, multiplier: number): number {
247
+ const product = BigInt(value) * BigInt(multiplier);
248
+ return Number((product + 9_999n) / 10_000n);
249
+ }
250
+
251
+ function compareCodeUnits(left: string, right: string): number {
252
+ return left < right ? -1 : left > right ? 1 : 0;
253
+ }
@@ -0,0 +1,94 @@
1
+ /** @beta */
2
+ export const PROMOTION_ENGINE_V2 = "promotion-engine.v2" as const;
3
+
4
+ /** @beta */
5
+ export const PROMOTION_PURCHASE_SCOPES = ["one_time", "subscription_initial"] as const;
6
+ /** @beta */
7
+ export type PromotionPurchaseScope = (typeof PROMOTION_PURCHASE_SCOPES)[number];
8
+
9
+ /** @beta */
10
+ export const PROMOTION_ADJUSTMENT_LANES = ["product", "shipping"] as const;
11
+ /** @beta */
12
+ export type PromotionAdjustmentLane = (typeof PROMOTION_ADJUSTMENT_LANES)[number];
13
+
14
+ /** @beta */
15
+ export const PROMOTION_ADJUSTMENT_KINDS = [
16
+ "target_percentage",
17
+ "percentage",
18
+ "fixed_amount",
19
+ "free_shipping",
20
+ ] as const;
21
+ /** @beta */
22
+ export type PromotionAdjustmentKind = (typeof PROMOTION_ADJUSTMENT_KINDS)[number];
23
+
24
+ /** @beta */
25
+ export type PromotionAdjustmentSource = "automatic" | "code";
26
+
27
+ interface PromotionAdjustmentCandidateBase {
28
+ promotionId: string;
29
+ name: string;
30
+ scopes: readonly PromotionPurchaseScope[];
31
+ }
32
+
33
+ type PromotionAdjustmentTrigger =
34
+ | { source: "automatic"; codeId?: never; code?: never }
35
+ | { source: "code"; codeId: string; code?: string };
36
+
37
+ /** An already eligibility-filtered benefit considered by the pure v2 engine. */
38
+ /** @beta */
39
+ export type PromotionAdjustmentCandidate = PromotionAdjustmentCandidateBase & PromotionAdjustmentTrigger & (
40
+ | { lane: "product"; kind: "target_percentage"; valueBps: number; valueMinor?: never }
41
+ | { lane: "product"; kind: "fixed_amount"; valueMinor: number; valueBps?: never }
42
+ | { lane: "shipping"; kind: "percentage"; valueBps: number; valueMinor?: never }
43
+ | { lane: "shipping"; kind: "fixed_amount"; valueMinor: number; valueBps?: never }
44
+ | { lane: "shipping"; kind: "free_shipping"; valueBps?: never; valueMinor?: never }
45
+ );
46
+
47
+ /** @beta */
48
+ export interface PromotionAdjustmentContext {
49
+ purchaseScope: PromotionPurchaseScope;
50
+ /** Product price before bundle, subscription, or acquisition benefits. */
51
+ referenceProductMinor: number;
52
+ /** Product price after automatic pricing benefits, before a v2 promotion. */
53
+ currentProductMinor: number;
54
+ shippingMinor: number;
55
+ /** Required adopter policy, expressed in the checkout currency's minor units. */
56
+ minimumProductPayableMinor: number;
57
+ }
58
+
59
+ /** @beta */
60
+ export interface AppliedAdjustment {
61
+ engineVersion: typeof PROMOTION_ENGINE_V2;
62
+ promotionId: string;
63
+ codeId?: string;
64
+ code?: string;
65
+ name: string;
66
+ source: PromotionAdjustmentSource;
67
+ lane: PromotionAdjustmentLane;
68
+ kind: PromotionAdjustmentKind;
69
+ amountOffMinor: number;
70
+ beforeMinor: number;
71
+ afterMinor: number;
72
+ floorApplied: boolean;
73
+ targetEffectiveDiscountBps?: number;
74
+ }
75
+
76
+ /** @beta */
77
+ export type PromotionCodeRejectionReason = "scope_not_applicable" | "better_price_exists";
78
+
79
+ /** @beta */
80
+ export interface PromotionCodeRejection {
81
+ codeId?: string;
82
+ code?: string;
83
+ reason: PromotionCodeRejectionReason;
84
+ }
85
+
86
+ /** @beta */
87
+ export interface PromotionAdjustmentResult {
88
+ engineVersion: typeof PROMOTION_ENGINE_V2;
89
+ adjustments: AppliedAdjustment[];
90
+ rejectedCodes: PromotionCodeRejection[];
91
+ productPayableMinor: number;
92
+ shippingPayableMinor: number;
93
+ effectiveProductDiscountBps: number;
94
+ }
@@ -0,0 +1,8 @@
1
+ /** @beta */
2
+ export * from "./types.js";
3
+ /** @beta */
4
+ export * from "./promoEvaluator.js";
5
+ /** @beta */
6
+ export * from "./adjustmentTypes.js";
7
+ /** @beta */
8
+ export * from "./adjustmentEngine.js";
@@ -0,0 +1,137 @@
1
+ import type { AppliedPromotion, EvaluateNowInput, PromotionRow } from "./types.js";
2
+
3
+ /**
4
+ * Deterministic promotion evaluator shared by preview and finalization callers.
5
+ * The same `(cart, candidates, now)` triple always yields the same
6
+ * `AppliedPromotion[]`, so downstream quote/order layers can compare the
7
+ * previewed discount with the persisted one.
8
+ *
9
+ * Two independent lanes:
10
+ * - order_total lane (percentage / fixed_amount): exclusivity applies.
11
+ * - shipping lane (free_shipping): evaluated separately so an order-total
12
+ * promo never suppresses free shipping.
13
+ *
14
+ * Within each lane, candidates are filtered to active, in-window, in-region,
15
+ * eligible-for-this-cart; coupon promotions additionally require the code in
16
+ * `cart.applied_codes`.
17
+ */
18
+ /** @beta */
19
+ export function evaluatePromos(input: EvaluateNowInput): AppliedPromotion[] {
20
+ const { cart, candidates, now } = input;
21
+ const eligible = candidates.filter((p) => promoEligibilityFailure(p, cart, now) === null && couponCodePresent(p, cart));
22
+
23
+ const shippingLane = eligible.filter((p) => p.discount_type === "free_shipping");
24
+ const orderLane = eligible.filter((p) => p.discount_type !== "free_shipping");
25
+
26
+ const applied: AppliedPromotion[] = [];
27
+
28
+ let exclusiveBlocked = false;
29
+ for (const promo of sortByPriority(orderLane, cart)) {
30
+ if (exclusiveBlocked) break;
31
+ const amount = computeAmountOff(promo, cart);
32
+ if (amount <= 0) continue;
33
+ applied.push(toApplied(promo, amount));
34
+ if (promo.stacking_rule === "exclusive") exclusiveBlocked = true;
35
+ }
36
+
37
+ const shippingPromo = sortByPriority(shippingLane, cart)[0];
38
+ if (shippingPromo) {
39
+ const amount = computeAmountOff(shippingPromo, cart);
40
+ if (amount > 0) applied.push(toApplied(shippingPromo, amount));
41
+ }
42
+
43
+ return applied;
44
+ }
45
+
46
+ /**
47
+ * Why a promo does not apply to this cart, or `null` if it would apply. Used by
48
+ * quote/finalization layers to classify rejected coupon codes. A promo that is
49
+ * eligible but merely loses to a larger exclusive promo returns `null` here.
50
+ */
51
+ /** @beta */
52
+ export function promoEligibilityFailure(
53
+ promo: PromotionRow,
54
+ cart: EvaluateNowInput["cart"],
55
+ now: string,
56
+ ): "expired" | "not_eligible" | null {
57
+ if (promo.status !== "active") return "not_eligible";
58
+ if (!promo.region_availability.includes(cart.region_code)) return "not_eligible";
59
+ if (!(promo.valid_from <= now && (promo.valid_to === null || promo.valid_to >= now))) return "expired";
60
+ if (!isEligibleForCart(promo, cart)) return "not_eligible";
61
+ return null;
62
+ }
63
+
64
+ function couponCodePresent(promo: PromotionRow, cart: EvaluateNowInput["cart"]): boolean {
65
+ return promo.trigger_type !== "coupon_code" || (promo.code !== null && cart.applied_codes.includes(promo.code));
66
+ }
67
+
68
+ function isEligibleForCart(promo: PromotionRow, cart: EvaluateNowInput["cart"]): boolean {
69
+ const elig = promo.eligibility;
70
+ const isFirstOrderPromo =
71
+ elig.first_purchase === true ||
72
+ elig.first_subscription_purchase === true ||
73
+ elig.first_onetime_purchase === true;
74
+ if (
75
+ isFirstOrderPromo &&
76
+ cart.device_first_order_redeemed === true &&
77
+ cart.email_eligibility_confirmed !== true
78
+ ) {
79
+ return false;
80
+ }
81
+ if (typeof elig.first_purchase === "boolean") {
82
+ if (elig.first_purchase === true && cart.client_orders_count !== 0) return false;
83
+ if (elig.first_purchase === false && cart.client_orders_count === 0) return false;
84
+ }
85
+ if (elig.first_subscription_purchase === true && (cart.client_subscription_orders_count ?? 0) !== 0) return false;
86
+ if (elig.first_onetime_purchase === true && (cart.client_onetime_orders_count ?? 0) !== 0) return false;
87
+ if (typeof elig.returning_customer === "boolean") {
88
+ if (elig.returning_customer === true && cart.client_orders_count === 0) return false;
89
+ if (elig.returning_customer === false && cart.client_orders_count !== 0) return false;
90
+ }
91
+ if (typeof elig.min_cart_minor === "number") {
92
+ if (cart.cart_subtotal_minor < elig.min_cart_minor) return false;
93
+ }
94
+ const cartModeMatch = promo.applies_to_payload.cart_mode;
95
+ if (typeof cartModeMatch === "string" && cartModeMatch !== cart.cart_mode) return false;
96
+ return true;
97
+ }
98
+
99
+ function computeAmountOff(promo: PromotionRow, cart: EvaluateNowInput["cart"]): number {
100
+ switch (promo.discount_type) {
101
+ case "percentage":
102
+ return Math.floor((cart.cart_subtotal_minor * promo.discount_value) / 100);
103
+ case "fixed_amount":
104
+ return Math.min(promo.discount_value, cart.cart_subtotal_minor);
105
+ case "free_shipping":
106
+ return cart.shipping_amount_minor;
107
+ default:
108
+ return 0;
109
+ }
110
+ }
111
+
112
+ function toApplied(promo: PromotionRow, amount: number): AppliedPromotion {
113
+ return {
114
+ promotion_id: promo.id,
115
+ code: promo.code,
116
+ name: promo.name,
117
+ amount_off_minor: amount,
118
+ applies_to: promo.applies_to_kind,
119
+ discount_type: promo.discount_type,
120
+ reason_code: promo.code ? `promo:${promo.code}` : `promo:${normalizeReasonName(promo.name)}`,
121
+ };
122
+ }
123
+
124
+ function sortByPriority(promos: PromotionRow[], cart: EvaluateNowInput["cart"]): PromotionRow[] {
125
+ return [...promos].sort((a, b) => {
126
+ if (a.stacking_rule === "exclusive" && b.stacking_rule !== "exclusive") return -1;
127
+ if (a.stacking_rule !== "exclusive" && b.stacking_rule === "exclusive") return 1;
128
+ const amountA = computeAmountOff(a, cart);
129
+ const amountB = computeAmountOff(b, cart);
130
+ if (amountA !== amountB) return amountB - amountA;
131
+ return a.name.localeCompare(b.name);
132
+ });
133
+ }
134
+
135
+ function normalizeReasonName(name: string): string {
136
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
137
+ }
@@ -0,0 +1,97 @@
1
+ /** @beta */
2
+ export const PROMOTION_TRIGGER_TYPES = ["coupon_code", "automatic", "referral"] as const;
3
+ /** @beta */
4
+ export type PromotionTriggerType = (typeof PROMOTION_TRIGGER_TYPES)[number];
5
+
6
+ /** @beta */
7
+ export const PROMOTION_DISCOUNT_TYPES = ["percentage", "fixed_amount", "free_shipping"] as const;
8
+ /** @beta */
9
+ export type PromotionDiscountType = (typeof PROMOTION_DISCOUNT_TYPES)[number];
10
+
11
+ /** @beta */
12
+ export const PROMOTION_APPLIES_TO_KINDS = ["order_total", "line_with_variant", "line_with_category"] as const;
13
+ /** @beta */
14
+ export type PromotionAppliesToKind = (typeof PROMOTION_APPLIES_TO_KINDS)[number];
15
+
16
+ /** @beta */
17
+ export const PROMOTION_STACKING_RULES = [
18
+ "exclusive",
19
+ "stackable_with_any",
20
+ "stackable_with_loyalty",
21
+ ] as const;
22
+ /** @beta */
23
+ export type PromotionStackingRule = (typeof PROMOTION_STACKING_RULES)[number];
24
+
25
+ /** @beta */
26
+ export interface PromotionRow {
27
+ id: string;
28
+ code: string | null;
29
+ name: string;
30
+ trigger_type: PromotionTriggerType;
31
+ discount_type: PromotionDiscountType;
32
+ discount_value: number;
33
+ applies_to_kind: PromotionAppliesToKind;
34
+ applies_to_payload: Record<string, unknown>;
35
+ stacking_rule: PromotionStackingRule;
36
+ eligibility: Record<string, unknown>;
37
+ valid_from: string;
38
+ valid_to: string | null;
39
+ status: string;
40
+ region_availability: string[];
41
+ /**
42
+ * Redemption caps. Enforced at quote-evaluation time by the caller before the
43
+ * candidate set reaches `evaluatePromos`, never by failing a paid order.
44
+ * Null = uncapped. Optional so non-DB callers/fixtures need not set them.
45
+ */
46
+ redemption_limit_global?: number | null;
47
+ redemption_limit_per_customer?: number | null;
48
+ }
49
+
50
+ /** @beta */
51
+ export interface PromoEvaluationCart {
52
+ region_code: string;
53
+ cart_mode: "one_time" | "subscription";
54
+ cart_subtotal_minor: number;
55
+ shipping_amount_minor: number;
56
+ /** All paid orders in any mode. Kept for the legacy `first_purchase` eligibility key. */
57
+ client_orders_count: number;
58
+ /** Paid subscription orders. Drives `first_subscription_purchase`. Optional for fixtures. */
59
+ client_subscription_orders_count?: number;
60
+ /** Paid one-time orders. Drives `first_onetime_purchase`. Optional for fixtures. */
61
+ client_onetime_orders_count?: number;
62
+ /**
63
+ * True when this device's first-party visitor id already has a paid first-order
64
+ * redemption. Denies any first-order promo regardless of client/email.
65
+ * Optional; absent means no device guard.
66
+ *
67
+ * Demoted to advisory when {@link email_eligibility_confirmed} is true: an
68
+ * account-level email signal outranks the device cookie.
69
+ */
70
+ device_first_order_redeemed?: boolean;
71
+ /**
72
+ * True when eligibility was resolved from a customer-provided email. When true,
73
+ * the email-derived order counts are authoritative and the device guard is
74
+ * advisory only. Optional; absent means the device guard stays in force.
75
+ */
76
+ email_eligibility_confirmed?: boolean;
77
+ applied_codes: string[];
78
+ }
79
+
80
+ /** @beta */
81
+ export interface AppliedPromotion {
82
+ promotion_id: string;
83
+ code: string | null;
84
+ /** Human-facing promotion name, surfaced as the quote discount label. */
85
+ name: string;
86
+ amount_off_minor: number;
87
+ applies_to: PromotionAppliesToKind;
88
+ discount_type: PromotionDiscountType;
89
+ reason_code: string;
90
+ }
91
+
92
+ /** @beta */
93
+ export interface EvaluateNowInput {
94
+ cart: PromoEvaluationCart;
95
+ candidates: ReadonlyArray<PromotionRow>;
96
+ now: string;
97
+ }
@@ -0,0 +1,41 @@
1
+ import type { RiskDecision, RiskSeverity } from "../types.js";
2
+ import { collectRiskRuleMatches } from "./rules.js";
3
+ import type { RiskEvaluationInput, RiskEvaluationOutput } from "./types.js";
4
+
5
+ const SEVERITY_ORDER: RiskSeverity[] = ["low", "medium", "high", "critical"];
6
+
7
+ /** @beta */
8
+ export function evaluateRisk(input: RiskEvaluationInput): RiskEvaluationOutput {
9
+ const matchedRules = collectRiskRuleMatches(input);
10
+ const score = Math.min(100, matchedRules.reduce((sum, rule) => sum + rule.score, 0));
11
+ const hasBlocklist = matchedRules.some((rule) => rule.code === "exact_blocklist_match");
12
+ const decision = decide(score, hasBlocklist);
13
+ const severity = matchedRules.reduce<RiskSeverity>(
14
+ (max, rule) => (SEVERITY_ORDER.indexOf(rule.severity) > SEVERITY_ORDER.indexOf(max) ? rule.severity : max),
15
+ "low",
16
+ );
17
+ const checkoutBlocked =
18
+ input.source === "checkout" && input.mode === "checkout_blocklist" && hasBlocklist;
19
+ const holdRequested =
20
+ input.source !== "checkout" && input.mode === "hold" && (decision === "manual_review" || decision === "block");
21
+
22
+ return {
23
+ decision,
24
+ score,
25
+ severity,
26
+ reasonCodes: matchedRules.map((rule) => rule.code),
27
+ matchedRules,
28
+ enforcement: {
29
+ mode: input.mode,
30
+ applied: input.mode !== "shadow" && (checkoutBlocked || holdRequested),
31
+ checkoutBlocked,
32
+ holdRequested,
33
+ },
34
+ };
35
+ }
36
+
37
+ function decide(score: number, hasBlocklist: boolean): RiskDecision {
38
+ if (hasBlocklist) return "block";
39
+ if (score >= 35) return "manual_review";
40
+ return "allow";
41
+ }