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