@openlup/platform 0.18.0 → 0.19.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 +47 -0
- package/README.md +24 -9
- package/dist/catalog/adminBundleReadHandler.d.ts +2 -1
- package/dist/catalog/adminBundleReadHandler.js +4 -4
- package/dist/catalog/bundleCatalogReadPort.d.ts +8 -1
- package/dist/catalog/bundleCatalogStore.d.ts +3 -1
- package/dist/catalog/bundleCatalogStore.js +5 -1
- package/dist/catalog/bundlePriceSelection.d.ts +13 -0
- package/dist/catalog/bundlePriceSelection.js +26 -0
- package/dist/catalog/contracts/bundleRead.d.ts +18 -0
- package/dist/catalog/contracts/draft.js +1 -1
- package/dist/catalog/effectiveBundleCatalogQueries.d.ts +8 -0
- package/dist/catalog/effectiveBundleCatalogQueries.js +79 -0
- package/dist/catalog/effectiveBundleCatalogStore.d.ts +4 -0
- package/dist/catalog/effectiveBundleCatalogStore.js +156 -0
- package/dist/catalog/index.d.ts +1 -0
- package/dist/catalog/index.js +1 -0
- package/dist/composition/index.d.ts +2 -0
- package/dist/composition/index.js +2 -0
- package/dist/composition/inventoryContribution.d.ts +16 -0
- package/dist/composition/inventoryContribution.js +17 -0
- package/dist/composition/shippingContribution.d.ts +16 -0
- package/dist/composition/shippingContribution.js +17 -0
- package/dist/inventory/bindings.d.ts +35 -0
- package/dist/inventory/bindings.js +45 -0
- package/dist/inventory/index.d.ts +1 -0
- package/dist/inventory/index.js +1 -0
- package/dist/pricing/commercePriceAuthority.d.ts +2 -0
- package/dist/pricing/commercePriceAuthority.js +61 -0
- package/dist/pricing/commerceQuoteHandler.d.ts +7 -3
- package/dist/pricing/commerceQuoteHandler.js +16 -4
- package/dist/pricing/dbBackedCommerceQuotePort.d.ts +8 -0
- package/dist/pricing/dbBackedCommerceQuotePort.js +46 -29
- package/dist/pricing/index.d.ts +2 -1
- package/dist/pricing/index.js +1 -1
- package/dist/pricing/offerPolicySchemas.d.ts +2 -2
- package/dist/pricing/offerPricingSchemas.d.ts +2 -2
- package/dist/pricing/pricingPolicyToken.d.ts +2 -2
- package/dist/pricing/quoteSchemas.d.ts +18 -18
- package/dist/pricing/quoteShippingPromotions.d.ts +20 -0
- package/dist/pricing/quoteShippingPromotions.js +1 -0
- package/dist/promotions/adminPromotionCodesContracts.d.ts +7 -7
- package/dist/promotions/commerceQuoteReadPreparation.d.ts +3 -0
- package/dist/promotions/commerceQuoteReadPreparation.js +27 -0
- package/dist/promotions/index.d.ts +3 -0
- package/dist/promotions/index.js +1 -0
- package/dist/promotions/promotionQuoteAcceptance.d.ts +5 -0
- package/dist/promotions/promotionQuoteAcceptance.js +12 -12
- package/dist/promotions/promotionQuoteBinding.d.ts +4 -2
- package/dist/promotions/promotionQuoteBinding.js +5 -3
- package/dist/promotions/quoteAcquisitionBinding.d.ts +6 -0
- package/dist/promotions/quoteAcquisitionBinding.js +1 -0
- package/dist/promotions/quoteAutomaticPromotions.d.ts +3 -1
- package/dist/promotions/quoteAutomaticPromotions.js +28 -4
- package/dist/promotions/quotePromoDiscounts.d.ts +5 -1
- package/dist/promotions/quotePromoDiscounts.js +8 -2
- package/dist/promotions/quotePromotionV2.d.ts +7 -6
- package/dist/promotions/quotePromotionV2.js +15 -3
- package/dist/promotions/quoteShippingPolicy.d.ts +23 -0
- package/dist/promotions/quoteShippingPolicy.js +47 -0
- package/dist/shipping/bindings.d.ts +57 -0
- package/dist/shipping/bindings.js +46 -0
- package/dist/shipping/contracts/index.d.ts +1 -0
- package/dist/shipping/contracts/threshold.d.ts +22 -0
- package/dist/shipping/contracts/threshold.js +1 -0
- package/dist/shipping/index.d.ts +3 -0
- package/dist/shipping/index.js +2 -0
- package/dist/shipping/thresholdPolicy.d.ts +42 -0
- package/dist/shipping/thresholdPolicy.js +60 -0
- package/package.json +2 -2
- package/release-gates.json +136 -134
- package/src/catalog/adminBundleReadHandler.ts +6 -4
- package/src/catalog/bundleCatalogReadPort.ts +6 -2
- package/src/catalog/bundleCatalogStore.ts +5 -0
- package/src/catalog/bundlePriceSelection.ts +25 -0
- package/src/catalog/contracts/bundleRead.ts +2 -2
- package/src/catalog/contracts/draft.ts +1 -1
- package/src/catalog/effectiveBundleCatalogQueries.ts +80 -0
- package/src/catalog/effectiveBundleCatalogStore.ts +208 -0
- package/src/catalog/index.ts +1 -0
- package/src/composition/index.ts +2 -0
- package/src/composition/inventoryContribution.ts +24 -0
- package/src/composition/shippingContribution.ts +24 -0
- package/src/inventory/bindings.ts +61 -0
- package/src/inventory/index.ts +1 -0
- package/src/pricing/commercePriceAuthority.ts +65 -0
- package/src/pricing/commerceQuoteHandler.ts +20 -5
- package/src/pricing/dbBackedCommerceQuotePort.ts +50 -30
- package/src/pricing/index.ts +2 -1
- package/src/pricing/quoteShippingPromotions.ts +21 -0
- package/src/promotions/commerceQuoteReadPreparation.ts +29 -0
- package/src/promotions/index.ts +3 -0
- package/src/promotions/promotionQuoteAcceptance.ts +17 -10
- package/src/promotions/promotionQuoteBinding.ts +7 -2
- package/src/promotions/quoteAcquisitionBinding.ts +7 -0
- package/src/promotions/quoteAutomaticPromotions.ts +30 -4
- package/src/promotions/quotePromoDiscounts.ts +11 -2
- package/src/promotions/quotePromotionV2.ts +20 -4
- package/src/promotions/quoteShippingPolicy.ts +61 -0
- package/src/shipping/bindings.ts +74 -0
- package/src/shipping/contracts/index.ts +1 -0
- package/src/shipping/contracts/threshold.ts +22 -0
- package/src/shipping/index.ts +3 -0
- package/src/shipping/thresholdPolicy.ts +65 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CommercePromoDataPort } from "./promoDataPort.js";
|
|
2
|
+
|
|
3
|
+
/** Allocate inside one quote resolution; clone each evaluator's mutable raw view. */
|
|
4
|
+
export function memoizeCommercePromoReads(inner: CommercePromoDataPort): CommercePromoDataPort {
|
|
5
|
+
const definitions = memoize(() => inner.listActivePromotions());
|
|
6
|
+
const paid = memoize((id: string) => inner.countPaidOrders(id));
|
|
7
|
+
const modes = memoize((id: string) => inner.countPaidOrdersByMode(id));
|
|
8
|
+
const redemptions = memoize((id: string | null) => inner.redemptionCounts(id));
|
|
9
|
+
const device = memoize((id: string | null) => inner.deviceFirstOrderRedeemed(id));
|
|
10
|
+
return {
|
|
11
|
+
listActivePromotions: async () => structuredClone(await definitions()),
|
|
12
|
+
countPaidOrders: paid,
|
|
13
|
+
countPaidOrdersByMode: async (id) => ({ ...await modes(id) }),
|
|
14
|
+
redemptionCounts: async (id) => structuredClone(await redemptions(id)),
|
|
15
|
+
deviceFirstOrderRedeemed: device,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function memoize<A extends readonly unknown[], T>(read: (...args: A) => Promise<T>): (...args: A) => Promise<T> {
|
|
19
|
+
const values = new Map<string, Promise<T>>();
|
|
20
|
+
return (...args) => {
|
|
21
|
+
const key = JSON.stringify(args);
|
|
22
|
+
let value = values.get(key);
|
|
23
|
+
if (!value) {
|
|
24
|
+
value = Promise.resolve().then(() => read(...args));
|
|
25
|
+
values.set(key, value);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/promotions/index.ts
CHANGED
|
@@ -20,3 +20,6 @@ export * from "./promotionClaimSweepPort.js";
|
|
|
20
20
|
export * from "./promotionClaims.js";
|
|
21
21
|
export * from "./sql.js";
|
|
22
22
|
export type { PromotionStatus, AdminPromotion, AdminPromotionV2Mirror, AdminPromotionSemanticBenefit, AdminPromotionsListResponse, AdminPromotionUpdatePayload, AdminPromotionUpdateRequest, AdminPromotionUpdateResponse } from "./contracts/index.js";
|
|
23
|
+
export type { QuoteAcquisitionBinding } from "./quoteAcquisitionBinding.js";
|
|
24
|
+
export { createQuoteShippingPromotions } from "./quoteShippingPolicy.js";
|
|
25
|
+
export type { QuoteShippingPromotionsDeps } from "./quoteShippingPolicy.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ShippingTerms } from "../shipping/index.js";
|
|
1
2
|
import type { ResolvedAcquisitionTerms } from "../pricing/index.js";
|
|
2
3
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -48,6 +49,7 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
48
49
|
request: Request;
|
|
49
50
|
quote: CommerceQuote;
|
|
50
51
|
acquisitionTerms?: ResolvedAcquisitionTerms;
|
|
52
|
+
shippingTerms?: ShippingTerms;
|
|
51
53
|
keyring: PromotionAcceptanceKeyring;
|
|
52
54
|
now?: Date;
|
|
53
55
|
}): string | null {
|
|
@@ -58,11 +60,12 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
58
60
|
request: Request;
|
|
59
61
|
quote: CommerceQuote;
|
|
60
62
|
acquisitionTerms?: ResolvedAcquisitionTerms;
|
|
63
|
+
shippingTerms?: ShippingTerms;
|
|
61
64
|
keyring: PromotionAcceptanceKeyring;
|
|
62
65
|
now?: Date;
|
|
63
66
|
}): { token: string | null; diagnostic: PromotionAcceptanceDiagnostic } {
|
|
64
67
|
const evidence = v2Discounts(input.quote);
|
|
65
|
-
if (evidence.length === 0 && !input.acquisitionTerms) return {
|
|
68
|
+
if (evidence.length === 0 && !input.acquisitionTerms && !input.shippingTerms) return {
|
|
66
69
|
token: null,
|
|
67
70
|
diagnostic: rejected("no_v2_adjustment"),
|
|
68
71
|
};
|
|
@@ -83,8 +86,8 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
83
86
|
engine: "promotion-engine.v2",
|
|
84
87
|
requestBinding: requestBinding(input.request, input.keyring.current),
|
|
85
88
|
codeBinding: codeBinding(input.request.promoCodes, input.keyring.current),
|
|
86
|
-
quoteBinding: quoteBinding(input.quote, input.keyring.current, input.acquisitionTerms),
|
|
87
|
-
sectionBindings: sectionBindings(input.quote, input.keyring.current, input.acquisitionTerms),
|
|
89
|
+
quoteBinding: quoteBinding(input.quote, input.keyring.current, input.acquisitionTerms, input.shippingTerms),
|
|
90
|
+
sectionBindings: sectionBindings(input.quote, input.keyring.current, input.acquisitionTerms, input.shippingTerms),
|
|
88
91
|
totalMinor: input.quote.totalGross.amountMinor,
|
|
89
92
|
currency: input.quote.totalGross.currency,
|
|
90
93
|
});
|
|
@@ -105,6 +108,7 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
105
108
|
request: Request;
|
|
106
109
|
quote: CommerceQuote;
|
|
107
110
|
acquisitionTerms?: ResolvedAcquisitionTerms;
|
|
111
|
+
shippingTerms?: ShippingTerms;
|
|
108
112
|
expectedTotal: { amountMinor: number; currency: string };
|
|
109
113
|
keyring: PromotionAcceptanceKeyring;
|
|
110
114
|
now?: Date;
|
|
@@ -117,6 +121,7 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
117
121
|
request: Request;
|
|
118
122
|
quote: CommerceQuote;
|
|
119
123
|
acquisitionTerms?: ResolvedAcquisitionTerms;
|
|
124
|
+
shippingTerms?: ShippingTerms;
|
|
120
125
|
expectedTotal: { amountMinor: number; currency: string };
|
|
121
126
|
keyring: PromotionAcceptanceKeyring;
|
|
122
127
|
now?: Date;
|
|
@@ -131,8 +136,8 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
131
136
|
if (payload.codeBinding !== codeBinding(input.request.promoCodes, secret)) {
|
|
132
137
|
return { accepted: false, reason: "code_mismatch", ...base };
|
|
133
138
|
}
|
|
134
|
-
if (payload.quoteBinding !== quoteBinding(input.quote, secret, input.acquisitionTerms)) {
|
|
135
|
-
const mismatchField = namedMismatchField(payload.sectionBindings, input.quote, secret, input.acquisitionTerms);
|
|
139
|
+
if (payload.quoteBinding !== quoteBinding(input.quote, secret, input.acquisitionTerms, input.shippingTerms)) {
|
|
140
|
+
const mismatchField = namedMismatchField(payload.sectionBindings, input.quote, secret, input.acquisitionTerms, input.shippingTerms);
|
|
136
141
|
return { accepted: false, reason: "quote_mismatch", mismatchField, ...base };
|
|
137
142
|
}
|
|
138
143
|
if (payload.totalMinor !== input.expectedTotal.amountMinor) {
|
|
@@ -141,7 +146,7 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
141
146
|
if (payload.currency !== input.expectedTotal.currency) {
|
|
142
147
|
return { accepted: false, reason: "currency_mismatch", ...base };
|
|
143
148
|
}
|
|
144
|
-
if (v2Discounts(input.quote).length === 0 && !input.acquisitionTerms) {
|
|
149
|
+
if (v2Discounts(input.quote).length === 0 && !input.acquisitionTerms && !input.shippingTerms) {
|
|
145
150
|
return { accepted: false, reason: "no_v2_adjustment_in_quote", ...base };
|
|
146
151
|
}
|
|
147
152
|
return { accepted: true, reason: "accepted", ...base };
|
|
@@ -227,16 +232,17 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
227
232
|
return hmac(secret, stableJson([...new Set(codes.map((code) => code.trim().toUpperCase()))].sort()));
|
|
228
233
|
}
|
|
229
234
|
|
|
230
|
-
function quoteBinding(quote: CommerceQuote, secret: string, acquisitionTerms?: ResolvedAcquisitionTerms): string {
|
|
231
|
-
return hmac(secret, stableJson(projectPromotionQuoteMoney(quote, acquisitionTerms)));
|
|
235
|
+
function quoteBinding(quote: CommerceQuote, secret: string, acquisitionTerms?: ResolvedAcquisitionTerms, shippingTerms?: ShippingTerms): string {
|
|
236
|
+
return hmac(secret, stableJson(projectPromotionQuoteMoney(quote, acquisitionTerms, shippingTerms)));
|
|
232
237
|
}
|
|
233
238
|
|
|
234
239
|
function sectionBindings(
|
|
235
240
|
quote: CommerceQuote,
|
|
236
241
|
secret: string,
|
|
237
242
|
acquisitionTerms?: ResolvedAcquisitionTerms,
|
|
243
|
+
shippingTerms?: ShippingTerms,
|
|
238
244
|
): Record<PromotionQuoteMoneySectionKey, string> {
|
|
239
|
-
const sections = promotionQuoteMoneySections(quote, acquisitionTerms);
|
|
245
|
+
const sections = promotionQuoteMoneySections(quote, acquisitionTerms, shippingTerms);
|
|
240
246
|
return Object.fromEntries(PROMOTION_QUOTE_MONEY_SECTIONS
|
|
241
247
|
.map((section) => [section, hmac(secret, stableJson(sections[section]))])) as Record<
|
|
242
248
|
PromotionQuoteMoneySectionKey, string>;
|
|
@@ -251,9 +257,10 @@ export function createPromotionQuoteAcceptance<Request extends { promoCodes: str
|
|
|
251
257
|
quote: CommerceQuote,
|
|
252
258
|
secret: string,
|
|
253
259
|
acquisitionTerms?: ResolvedAcquisitionTerms,
|
|
260
|
+
shippingTerms?: ShippingTerms,
|
|
254
261
|
): PromotionQuoteMismatchField {
|
|
255
262
|
if (!issued) return "other";
|
|
256
|
-
const verified = sectionBindings(quote, secret, acquisitionTerms);
|
|
263
|
+
const verified = sectionBindings(quote, secret, acquisitionTerms, shippingTerms);
|
|
257
264
|
return PROMOTION_QUOTE_MONEY_SECTIONS
|
|
258
265
|
.find((section) => issued[section] !== verified[section]) ?? "other";
|
|
259
266
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ShippingTerms } from "../shipping/index.js";
|
|
1
2
|
import { acquisitionQuoteBinding, type ResolvedAcquisitionTerms } from "../pricing/index.js";
|
|
2
3
|
import type {
|
|
3
4
|
CommerceMoney,
|
|
@@ -50,6 +51,7 @@ export interface PromotionQuoteMoneyDiscount {
|
|
|
50
51
|
|
|
51
52
|
export interface PromotionQuoteMoneyProjection {
|
|
52
53
|
acquisitionTerms?: ResolvedAcquisitionTerms;
|
|
54
|
+
shippingTerms?: ShippingTerms;
|
|
53
55
|
currency: string;
|
|
54
56
|
totalGross: CommerceMoney;
|
|
55
57
|
netTotal: CommerceMoney;
|
|
@@ -85,7 +87,7 @@ export type PromotionQuoteMismatchField = (typeof PROMOTION_QUOTE_MISMATCH_FIELD
|
|
|
85
87
|
* Discount order is preserved: the promotion engine emits its lanes
|
|
86
88
|
* deterministically, so a reordering is a real evaluation difference.
|
|
87
89
|
*/
|
|
88
|
-
export function projectPromotionQuoteMoney(quote: CommerceQuote, acquisitionTerms?: ResolvedAcquisitionTerms): PromotionQuoteMoneyProjection {
|
|
90
|
+
export function projectPromotionQuoteMoney(quote: CommerceQuote, acquisitionTerms?: ResolvedAcquisitionTerms, shippingTerms?: ShippingTerms): PromotionQuoteMoneyProjection {
|
|
89
91
|
return {
|
|
90
92
|
...acquisitionQuoteBinding(acquisitionTerms),
|
|
91
93
|
currency: quote.currency,
|
|
@@ -96,6 +98,7 @@ export function projectPromotionQuoteMoney(quote: CommerceQuote, acquisitionTerm
|
|
|
96
98
|
discountTotalGross: quote.discountTotalGross,
|
|
97
99
|
shippingGross: quote.shippingGross ?? null,
|
|
98
100
|
shippingDiscountGross: quote.shippingDiscountGross ?? null,
|
|
101
|
+
...(shippingTerms ? { shippingTerms } : {}),
|
|
99
102
|
lines: quote.lines.map((line) => ({
|
|
100
103
|
sku: line.sku,
|
|
101
104
|
quantity: line.quantity,
|
|
@@ -125,14 +128,16 @@ export function projectPromotionQuoteMoney(quote: CommerceQuote, acquisitionTerm
|
|
|
125
128
|
export function promotionQuoteMoneySections(
|
|
126
129
|
quote: CommerceQuote,
|
|
127
130
|
acquisitionTerms?: ResolvedAcquisitionTerms,
|
|
131
|
+
shippingTerms?: ShippingTerms,
|
|
128
132
|
): Record<PromotionQuoteMoneySection, unknown> {
|
|
129
|
-
const money = projectPromotionQuoteMoney(quote, acquisitionTerms);
|
|
133
|
+
const money = projectPromotionQuoteMoney(quote, acquisitionTerms, shippingTerms);
|
|
130
134
|
return {
|
|
131
135
|
lines: money.lines,
|
|
132
136
|
discounts: money.discounts,
|
|
133
137
|
shipping: {
|
|
134
138
|
shippingGross: money.shippingGross,
|
|
135
139
|
shippingDiscountGross: money.shippingDiscountGross,
|
|
140
|
+
...(money.shippingTerms ? { shippingTerms: money.shippingTerms } : {}),
|
|
136
141
|
},
|
|
137
142
|
totals: {
|
|
138
143
|
...acquisitionQuoteBinding(money.acquisitionTerms),
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PromotionRow } from "@openlup/core/promo";
|
|
2
|
+
import type { AcquisitionFinancialPolicy } from "../pricing/index.js";
|
|
3
|
+
|
|
4
|
+
export interface QuoteAcquisitionBinding<Request = unknown> {
|
|
5
|
+
financialPolicy: AcquisitionFinancialPolicy<PromotionRow>;
|
|
6
|
+
capability(request: Request): unknown;
|
|
7
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { selectAcquisitionTerms, assertAcquisitionTermsCapability, ACQUISITION_TERMS_CAPABILITY } from "../pricing/index.js";
|
|
2
|
+
import type { QuoteAcquisitionBinding } from "./quoteAcquisitionBinding.js";
|
|
1
3
|
import type { CreateQuoteRequest } from "../pricing/index.js";
|
|
2
4
|
import { PROMOTION_ENGINE_V2 } from "../pricing/index.js";
|
|
3
5
|
import type { CreateQuoteOptions } from "../pricing/index.js";
|
|
@@ -5,7 +7,8 @@ import type { CommercePromoDataPort } from "./promoDataPort.js";
|
|
|
5
7
|
import type { createQuotePromoDiscounts, QuoteDiscountResult } from "./quotePromoDiscounts.js";
|
|
6
8
|
import type { createQuotePromotionV2 } from "./quotePromotionV2.js";
|
|
7
9
|
|
|
8
|
-
export function createQuoteAutomaticPromotions<Request extends CreateQuoteRequest & { visitorId?: string }>({ resolveEmailEligibility, evaluateOrderTotalDiscounts, evaluateAutomaticPromotionV2, legacyPromotionDataPort }: {
|
|
10
|
+
export function createQuoteAutomaticPromotions<Request extends CreateQuoteRequest & { visitorId?: string }>({ resolveEmailEligibility, evaluateOrderTotalDiscounts, evaluateAutomaticPromotionV2, legacyPromotionDataPort, acquisition }: {
|
|
11
|
+
acquisition?: QuoteAcquisitionBinding<Request>;
|
|
9
12
|
resolveEmailEligibility(request: Request): boolean;
|
|
10
13
|
evaluateOrderTotalDiscounts: ReturnType<typeof createQuotePromoDiscounts>["evaluateOrderTotalDiscounts"];
|
|
11
14
|
evaluateAutomaticPromotionV2: ReturnType<typeof createQuotePromotionV2>["evaluateAutomaticPromotionV2"];
|
|
@@ -23,10 +26,28 @@ export function createQuoteAutomaticPromotions<Request extends CreateQuoteReques
|
|
|
23
26
|
}): Promise<QuoteDiscountResult> {
|
|
24
27
|
if (!input.promoDataPort) return { discounts: [], codeRejections: [] };
|
|
25
28
|
|
|
29
|
+
let promoDataPort = input.promoDataPort;
|
|
30
|
+
let selection = null;
|
|
31
|
+
let acquisitionCapability: unknown;
|
|
32
|
+
if (acquisition) {
|
|
33
|
+
const clientId = input.options?.clientId ?? null;
|
|
34
|
+
const [paid, allRows] = await Promise.all([
|
|
35
|
+
clientId ? promoDataPort.countPaidOrdersByMode(clientId) : Promise.resolve({ oneTime: 0, subscription: 0 }),
|
|
36
|
+
promoDataPort.listActivePromotions(),
|
|
37
|
+
]);
|
|
38
|
+
const existingSubscriber = input.request.mode === "subscription" && paid.subscription > 0;
|
|
39
|
+
const rows = existingSubscriber ? allRows.filter((row) =>
|
|
40
|
+
!acquisition.financialPolicy.facts(row).metadataPresent && row.eligibility.first_subscription_purchase !== true) : allRows;
|
|
41
|
+
promoDataPort = { ...promoDataPort, listActivePromotions: async () => rows, countPaidOrdersByMode: async () => paid };
|
|
42
|
+
selection = input.request.mode === "subscription" && !existingSubscriber
|
|
43
|
+
? selectAcquisitionTerms(rows, input.atTime, input.regionCode, acquisition.financialPolicy) : null;
|
|
44
|
+
acquisitionCapability = acquisition.capability(input.request);
|
|
45
|
+
assertAcquisitionTermsCapability(selection, acquisitionCapability, input.options?.pricingPolicy?.promotionEngineVersion);
|
|
46
|
+
}
|
|
26
47
|
const emailEligibilityConfirmed = resolveEmailEligibility(input.request);
|
|
27
48
|
if (input.options?.pricingPolicy?.promotionEngineVersion === PROMOTION_ENGINE_V2) {
|
|
28
49
|
return evaluateAutomaticPromotionV2({
|
|
29
|
-
promoDataPort
|
|
50
|
+
promoDataPort,
|
|
30
51
|
clientId: input.options.clientId ?? null,
|
|
31
52
|
visitorId: input.request.visitorId ?? null,
|
|
32
53
|
mode: input.request.mode,
|
|
@@ -37,11 +58,12 @@ export function createQuoteAutomaticPromotions<Request extends CreateQuoteReques
|
|
|
37
58
|
shippingMinor: input.shippingGrossMinor,
|
|
38
59
|
atTime: input.atTime,
|
|
39
60
|
emailEligibilityConfirmed,
|
|
61
|
+
...(acquisition ? { acquisitionCapability } : {}),
|
|
40
62
|
});
|
|
41
63
|
}
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
promoDataPort: legacyPromotionDataPort(
|
|
65
|
+
const pending = evaluateOrderTotalDiscounts({
|
|
66
|
+
promoDataPort: legacyPromotionDataPort(promoDataPort),
|
|
45
67
|
clientId: input.options?.clientId ?? null,
|
|
46
68
|
visitorId: input.request.visitorId ?? null,
|
|
47
69
|
mode: input.request.mode,
|
|
@@ -52,6 +74,10 @@ export function createQuoteAutomaticPromotions<Request extends CreateQuoteReques
|
|
|
52
74
|
atTime: input.atTime,
|
|
53
75
|
emailEligibilityConfirmed,
|
|
54
76
|
});
|
|
77
|
+
if (!acquisition) return pending;
|
|
78
|
+
const result = await pending;
|
|
79
|
+
return selection && acquisitionCapability === ACQUISITION_TERMS_CAPABILITY
|
|
80
|
+
? { ...result, acquisitionTerms: selection.terms } : result;
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
return { resolveAutomaticQuotePromotions };
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
+
import { filterAcquisitionPromotions } from "../pricing/index.js";
|
|
2
|
+
import type { ResolvedAcquisitionTerms } from "../pricing/index.js";
|
|
3
|
+
import type { QuoteAcquisitionBinding } from "./quoteAcquisitionBinding.js";
|
|
1
4
|
import type { CreateQuoteRequest } from "../pricing/index.js";
|
|
2
5
|
import type { CommerceCodeRejection, CommerceQuoteDiscount } from "../pricing/index.js";
|
|
3
6
|
import { evaluatePromos, promoEligibilityFailure } from "@openlup/core/promo";
|
|
4
7
|
import type { PromoEvaluationCart, PromotionRow } from "@openlup/core/promo";
|
|
5
8
|
import type { CommercePromoDataPort, PromotionRedemptionCount } from "./promoDataPort.js";
|
|
6
9
|
export interface QuoteDiscountResult {
|
|
10
|
+
acquisitionTerms?: ResolvedAcquisitionTerms;
|
|
7
11
|
/** Applied discounts: order_total (clamped to subtotal) + at most one shipping discount. */
|
|
8
12
|
discounts: CommerceQuoteDiscount[];
|
|
9
13
|
/** Submitted coupon codes that produced no discount, with the reason. */
|
|
10
14
|
codeRejections: CommerceCodeRejection[];
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
export function createQuotePromoDiscounts({ promotionCustomerSemantic }: { promotionCustomerSemantic: (row: PromotionRow) => CommerceQuoteDiscount["customerSemantic"] }) {
|
|
17
|
+
export function createQuotePromoDiscounts({ promotionCustomerSemantic, acquisition }: { acquisition?: Pick<QuoteAcquisitionBinding, "financialPolicy">; promotionCustomerSemantic: (row: PromotionRow) => CommerceQuoteDiscount["customerSemantic"] }) {
|
|
14
18
|
/**
|
|
15
19
|
* Evaluate active promotions for this cart and map them to quote discounts. Two lanes:
|
|
16
20
|
* `order_total` promos are clamped to the subtotal (so `discountTotal <= subtotal`), and
|
|
@@ -52,7 +56,12 @@ export function createQuotePromoDiscounts({ promotionCustomerSemantic }: { promo
|
|
|
52
56
|
input.promoDataPort.redemptionCounts(input.clientId),
|
|
53
57
|
input.promoDataPort.deviceFirstOrderRedeemed(input.visitorId ?? null),
|
|
54
58
|
]);
|
|
55
|
-
const
|
|
59
|
+
const customerCandidates = acquisition && input.mode === "subscription" && byMode.subscription > 0
|
|
60
|
+
? allCandidates.filter((row) => !acquisition.financialPolicy.facts(row).metadataPresent && row.eligibility.first_subscription_purchase !== true)
|
|
61
|
+
: allCandidates;
|
|
62
|
+
const effectiveCandidates = acquisition && input.mode === "subscription"
|
|
63
|
+
? filterAcquisitionPromotions(customerCandidates, input.atTime, input.regionCode, acquisition.financialPolicy) : customerCandidates;
|
|
64
|
+
const candidates = effectiveCandidates.filter(
|
|
56
65
|
(promo) => !capReached(promo, redemptionCounts.get(promo.id), input.clientId),
|
|
57
66
|
);
|
|
58
67
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { filterAcquisitionPromotions, selectAcquisitionTerms, assertAcquisitionTermsCapability, ACQUISITION_TERMS_CAPABILITY } from "../pricing/index.js";
|
|
2
|
+
import type { QuoteAcquisitionBinding } from "./quoteAcquisitionBinding.js";
|
|
3
|
+
import type { QuoteDiscountResult } from "./quotePromoDiscounts.js";
|
|
1
4
|
import { evaluatePromotionAdjustmentsV2, promoEligibilityFailure } from "@openlup/core/promo";
|
|
2
5
|
import type {
|
|
3
6
|
PromoEvaluationCart,
|
|
@@ -18,7 +21,8 @@ type V2Row = PromotionRow & {
|
|
|
18
21
|
v2_mirror_of?: unknown;
|
|
19
22
|
};
|
|
20
23
|
|
|
21
|
-
export function createQuotePromotionV2({ promotionCustomerSemantic, minimumProductPayableMinor: MINIMUM_PRODUCT_PAYABLE_MINOR, evaluateOrderTotalDiscounts }: {
|
|
24
|
+
export function createQuotePromotionV2({ promotionCustomerSemantic, minimumProductPayableMinor: MINIMUM_PRODUCT_PAYABLE_MINOR, evaluateOrderTotalDiscounts, acquisition }: {
|
|
25
|
+
acquisition?: QuoteAcquisitionBinding<CreateQuoteRequest>;
|
|
22
26
|
promotionCustomerSemantic: (row: PromotionRow) => CommerceQuoteDiscount["customerSemantic"];
|
|
23
27
|
minimumProductPayableMinor: number;
|
|
24
28
|
evaluateOrderTotalDiscounts: ReturnType<typeof createQuotePromoDiscounts>["evaluateOrderTotalDiscounts"];
|
|
@@ -37,7 +41,8 @@ export function createQuotePromotionV2({ promotionCustomerSemantic, minimumProdu
|
|
|
37
41
|
shippingMinor: number;
|
|
38
42
|
atTime: string;
|
|
39
43
|
emailEligibilityConfirmed?: boolean;
|
|
40
|
-
|
|
44
|
+
acquisitionCapability?: unknown;
|
|
45
|
+
}): Promise<QuoteDiscountResult> {
|
|
41
46
|
const byMode = input.clientId
|
|
42
47
|
? await input.promoDataPort.countPaidOrdersByMode(input.clientId)
|
|
43
48
|
: { oneTime: 0, subscription: 0 };
|
|
@@ -46,6 +51,14 @@ export function createQuotePromotionV2({ promotionCustomerSemantic, minimumProdu
|
|
|
46
51
|
input.promoDataPort.redemptionCounts(input.clientId),
|
|
47
52
|
input.promoDataPort.deviceFirstOrderRedeemed(input.visitorId ?? null),
|
|
48
53
|
]);
|
|
54
|
+
const existingSubscriber = input.mode === "subscription" && byMode.subscription > 0;
|
|
55
|
+
const candidatesForCustomer = acquisition && existingSubscriber ? rows.filter((row) =>
|
|
56
|
+
!acquisition.financialPolicy.facts(row).metadataPresent && row.eligibility.first_subscription_purchase !== true) : rows;
|
|
57
|
+
const selection = acquisition && input.mode === "subscription" && !existingSubscriber
|
|
58
|
+
? selectAcquisitionTerms(candidatesForCustomer, input.atTime, input.regionCode, acquisition.financialPolicy) : null;
|
|
59
|
+
if (acquisition) assertAcquisitionTermsCapability(selection, input.acquisitionCapability, "promotion-engine.v2");
|
|
60
|
+
const effectiveRows = acquisition && input.mode === "subscription"
|
|
61
|
+
? filterAcquisitionPromotions(candidatesForCustomer, input.atTime, input.regionCode, acquisition.financialPolicy) : candidatesForCustomer;
|
|
49
62
|
const cart: PromoEvaluationCart = {
|
|
50
63
|
region_code: input.regionCode,
|
|
51
64
|
cart_mode: input.mode === "subscription" ? "subscription" : "one_time",
|
|
@@ -59,7 +72,7 @@ export function createQuotePromotionV2({ promotionCustomerSemantic, minimumProdu
|
|
|
59
72
|
applied_codes: [],
|
|
60
73
|
};
|
|
61
74
|
|
|
62
|
-
const eligibleRows = (
|
|
75
|
+
const eligibleRows = (effectiveRows as V2Row[])
|
|
63
76
|
.filter((row) => row.promotion_engine_version === "promotion-engine.v2")
|
|
64
77
|
.filter((row) => row.trigger_type === "automatic")
|
|
65
78
|
.filter((row) => promoEligibilityFailure(row, cart, input.atTime) === null)
|
|
@@ -91,7 +104,8 @@ export function createQuotePromotionV2({ promotionCustomerSemantic, minimumProdu
|
|
|
91
104
|
};
|
|
92
105
|
});
|
|
93
106
|
const legacyFloor = await evaluateOrderTotalDiscounts({
|
|
94
|
-
promoDataPort: legacyPromotionDataPort(
|
|
107
|
+
promoDataPort: legacyPromotionDataPort(acquisition
|
|
108
|
+
? { ...input.promoDataPort, listActivePromotions: async () => effectiveRows } : input.promoDataPort),
|
|
95
109
|
clientId: input.clientId,
|
|
96
110
|
visitorId: input.visitorId,
|
|
97
111
|
mode: input.mode,
|
|
@@ -109,6 +123,8 @@ export function createQuotePromotionV2({ promotionCustomerSemantic, minimumProdu
|
|
|
109
123
|
return {
|
|
110
124
|
discounts,
|
|
111
125
|
codeRejections: reconcileLegacyCodeOutcomes(input.promoCodes ?? [], discounts, legacyFloor),
|
|
126
|
+
...(selection && input.acquisitionCapability === ACQUISITION_TERMS_CAPABILITY
|
|
127
|
+
? { acquisitionTerms: selection.terms } : {}),
|
|
112
128
|
};
|
|
113
129
|
}
|
|
114
130
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PROMOTION_ENGINE_V2 } from "../pricing/index.js";
|
|
2
|
+
import type { CreateQuoteRequest, QuoteShippingPromotionInput, QuoteShippingPromotionResult } from "../pricing/index.js";
|
|
3
|
+
import { resolveShippingPolicy, resolveThresholdShipping } from "../shipping/index.js";
|
|
4
|
+
import type { ShippingSettings } from "../shipping/index.js";
|
|
5
|
+
import type { CommercePromoDataPort } from "./promoDataPort.js";
|
|
6
|
+
import type { PromotionCodeQuotePort } from "./promotionCodeQuotePort.js";
|
|
7
|
+
import type { createQuoteAutomaticPromotions } from "./quoteAutomaticPromotions.js";
|
|
8
|
+
import type { createQuotePromotionCodeAdjustments } from "./quotePromotionCodeAdjustments.js";
|
|
9
|
+
import { memoizeCommercePromoReads } from "./commerceQuoteReadPreparation.js";
|
|
10
|
+
|
|
11
|
+
export interface QuoteShippingPromotionsDeps<Request extends CreateQuoteRequest & { visitorId?: string }> {
|
|
12
|
+
promoDataPort?: CommercePromoDataPort;
|
|
13
|
+
promotionCodeQuotePort?: PromotionCodeQuotePort;
|
|
14
|
+
commerceSettingsPort?: { getShippingSettings(): Promise<ShippingSettings> };
|
|
15
|
+
resolveAutomaticQuotePromotions: ReturnType<typeof createQuoteAutomaticPromotions<Request>>["resolveAutomaticQuotePromotions"];
|
|
16
|
+
applyPromotionCodesV2: ReturnType<typeof createQuotePromotionCodeAdjustments>["applyPromotionCodesV2"];
|
|
17
|
+
}
|
|
18
|
+
/** Evaluate both lanes again only when the post-product-discount threshold changes the fee. */
|
|
19
|
+
export function createQuoteShippingPromotions<Request extends CreateQuoteRequest & { visitorId?: string }>(deps: QuoteShippingPromotionsDeps<Request>) {
|
|
20
|
+
async function resolveQuoteShippingPromotions(input: QuoteShippingPromotionInput<Request>): Promise<QuoteShippingPromotionResult> {
|
|
21
|
+
const settings = deps.commerceSettingsPort
|
|
22
|
+
? await deps.commerceSettingsPort.getShippingSettings()
|
|
23
|
+
: { legacyFlatMinor: 0, policy: null };
|
|
24
|
+
const shipping = resolveShippingPolicy(settings, input.atTime, input.currency);
|
|
25
|
+
const promoDataPort = deps.promoDataPort ? memoizeCommercePromoReads(deps.promoDataPort) : undefined;
|
|
26
|
+
let candidates: ReturnType<PromotionCodeQuotePort["resolve"]> | undefined;
|
|
27
|
+
const port: PromotionCodeQuotePort | undefined = deps.promotionCodeQuotePort ? {
|
|
28
|
+
resolve: async (request) => structuredClone(await (candidates ??= deps.promotionCodeQuotePort!.resolve(request))),
|
|
29
|
+
} : undefined;
|
|
30
|
+
async function evaluate(shippingGrossMinor: number) {
|
|
31
|
+
const automatic = await deps.resolveAutomaticQuotePromotions({ ...input, promoDataPort, shippingGrossMinor });
|
|
32
|
+
const adjusted = await deps.applyPromotionCodesV2({
|
|
33
|
+
port,
|
|
34
|
+
preserveLegacyDiscountsWhenNoCandidates: input.options?.pricingPolicy?.promotionEngineVersion !== PROMOTION_ENGINE_V2,
|
|
35
|
+
clientId: input.options?.clientId ?? null,
|
|
36
|
+
mode: input.request.mode,
|
|
37
|
+
promoCodes: input.request.promoCodes,
|
|
38
|
+
referenceProductMinor: input.referenceProductMinor,
|
|
39
|
+
subtotalGrossMinor: input.subtotalGrossMinor,
|
|
40
|
+
shippingGrossMinor,
|
|
41
|
+
discounts: automatic.discounts,
|
|
42
|
+
codeRejections: automatic.codeRejections,
|
|
43
|
+
atTime: input.atTime,
|
|
44
|
+
});
|
|
45
|
+
return { ...adjusted, acquisitionTerms: automatic.acquisitionTerms };
|
|
46
|
+
}
|
|
47
|
+
let shippingGrossMinor = shipping.flatMinor;
|
|
48
|
+
let result = await evaluate(shippingGrossMinor);
|
|
49
|
+
const productDiscount = result.discounts.filter((discount) => discount.appliesTo !== "shipping")
|
|
50
|
+
.reduce((total, discount) => total + discount.amountOffMinor, 0);
|
|
51
|
+
if (shipping.terms) {
|
|
52
|
+
const resolved = resolveThresholdShipping(shipping.terms, input.subtotalGrossMinor - productDiscount);
|
|
53
|
+
if (resolved.grossMinor !== shippingGrossMinor) {
|
|
54
|
+
shippingGrossMinor = resolved.grossMinor;
|
|
55
|
+
result = await evaluate(shippingGrossMinor);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { ...result, shippingGrossMinor, shippingTerms: shipping.terms, hasShipping: deps.commerceSettingsPort != null };
|
|
59
|
+
}
|
|
60
|
+
return { resolveQuoteShippingPromotions };
|
|
61
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { BffResponder } from "../bff/index.js";
|
|
2
|
+
import type { CustomerAddressBookPort, CustomerAddressMutationPort, CustomerAddressesDeps } from "./addressPorts.js";
|
|
3
|
+
import type { CustomerAddressSchemas } from "./addressSchemas.js";
|
|
4
|
+
import type { DeliverySelectionPort } from "./deliverySelectionPort.js";
|
|
5
|
+
import type { DeliverySelectionSchemas } from "./handlerSchemas.js";
|
|
6
|
+
import type { PickupPointSearchPort } from "./pickupPointSearchPort.js";
|
|
7
|
+
import type { AdminShippingRateHandlerDeps, ShippingRateDataPort, ShippingRateHandlerSchemas } from "./shippingRatePorts.js";
|
|
8
|
+
import { createCustomerAddressesHandler } from "./customerAddressesHandler.js";
|
|
9
|
+
import { createAdminShippingRateHandler } from "./adminShippingRateHandler.js";
|
|
10
|
+
import { createDeliveryOptionsHandler, createPickupPointSearchHandler, createPickupPointValidationHandler } from "./deliverySelectionHandler.js";
|
|
11
|
+
|
|
12
|
+
type Store = {
|
|
13
|
+
addressBookPort?: CustomerAddressBookPort;
|
|
14
|
+
addressMutationPort?: CustomerAddressMutationPort;
|
|
15
|
+
shippingRateDataPort?: ShippingRateDataPort;
|
|
16
|
+
};
|
|
17
|
+
export interface ShippingBindingDependencies {
|
|
18
|
+
dataKind: string;
|
|
19
|
+
createManagedStore?: () => Store | null;
|
|
20
|
+
nativeStore?: Omit<Store, "addressMutationPort"> | null;
|
|
21
|
+
responder: BffResponder;
|
|
22
|
+
selection?: { port: DeliverySelectionPort; searchPort: PickupPointSearchPort; schemas: DeliverySelectionSchemas };
|
|
23
|
+
addresses?: { schemas: CustomerAddressSchemas; authenticateUser: CustomerAddressesDeps["authenticateUser"]; requireWrites: boolean };
|
|
24
|
+
rate?: { schemas: ShippingRateHandlerSchemas; authorizeAdmin: AdminShippingRateHandlerDeps["authorizeAdmin"]; resolveAdmin: AdminShippingRateHandlerDeps["resolveAdmin"] };
|
|
25
|
+
}
|
|
26
|
+
export interface ShippingBindings {
|
|
27
|
+
store: Store;
|
|
28
|
+
handlers: {
|
|
29
|
+
options: ReturnType<typeof createDeliveryOptionsHandler> | null;
|
|
30
|
+
search: ReturnType<typeof createPickupPointSearchHandler> | null;
|
|
31
|
+
validate: ReturnType<typeof createPickupPointValidationHandler> | null;
|
|
32
|
+
addresses: ReturnType<typeof createCustomerAddressesHandler> | null;
|
|
33
|
+
rate: ReturnType<typeof createAdminShippingRateHandler> | null;
|
|
34
|
+
};
|
|
35
|
+
readiness: { ready: boolean; reason: string | null };
|
|
36
|
+
unsupported: { addressWrites: string | null; flatRateSave: string | null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Explicit selected stores and host validation; importing this module activates nothing. */
|
|
40
|
+
export function createShippingBindings(deps: ShippingBindingDependencies): ShippingBindings {
|
|
41
|
+
const native = deps.dataKind === "postgres";
|
|
42
|
+
const addressWrites = native ? "shipping_native_address_writes_unsupported" : null;
|
|
43
|
+
const flatRateSave = native ? "shipping_native_flat_rate_save_unsupported" : null;
|
|
44
|
+
const refused = (reason: string): ShippingBindings => ({
|
|
45
|
+
store: {}, handlers: { options: null, search: null, validate: null, addresses: null, rate: null },
|
|
46
|
+
readiness: { ready: false, reason }, unsupported: { addressWrites, flatRateSave },
|
|
47
|
+
});
|
|
48
|
+
if (!native && deps.dataKind !== "supabase") return refused("shipping_store_incompatible");
|
|
49
|
+
if (native && deps.addresses?.requireWrites) return refused(addressWrites!);
|
|
50
|
+
if (native && deps.rate) return refused(flatRateSave!);
|
|
51
|
+
const selected: Store | null | undefined = native ? deps.nativeStore : deps.createManagedStore?.();
|
|
52
|
+
if ((deps.addresses || deps.rate) && !selected) return refused("shipping_store_required");
|
|
53
|
+
// Native address mutations remain unavailable even if an untyped host plants a writer.
|
|
54
|
+
const store: Store = { addressBookPort: selected?.addressBookPort, shippingRateDataPort: selected?.shippingRateDataPort,
|
|
55
|
+
addressMutationPort: native ? undefined : selected?.addressMutationPort };
|
|
56
|
+
if (deps.addresses && (typeof store.addressBookPort?.getAddressBook !== "function"
|
|
57
|
+
|| typeof deps.addresses.authenticateUser !== "function"
|
|
58
|
+
|| (deps.addresses.requireWrites && ![store.addressMutationPort?.upsertAddress, store.addressMutationPort?.deleteAddress].every(fn => typeof fn === "function")))) return refused("shipping_address_store_incompatible");
|
|
59
|
+
if (deps.rate && (![store.shippingRateDataPort?.getShippingFlatMinor, store.shippingRateDataPort?.setShippingFlatMinor,
|
|
60
|
+
store.shippingRateDataPort?.recordPromotionAudit, deps.rate.authorizeAdmin, deps.rate.resolveAdmin].every(fn => typeof fn === "function"))) return refused("shipping_rate_store_incompatible");
|
|
61
|
+
if (deps.selection && ![deps.selection.port?.listOptions, deps.selection.port?.validatePickupPoint, deps.selection.searchPort?.searchPickupPoints].every(fn => typeof fn === "function")) return refused("shipping_selection_incompatible");
|
|
62
|
+
return {
|
|
63
|
+
store,
|
|
64
|
+
handlers: {
|
|
65
|
+
options: deps.selection ? createDeliveryOptionsHandler(deps.selection.port, deps.selection.schemas, deps.responder) : null,
|
|
66
|
+
search: deps.selection ? createPickupPointSearchHandler(deps.selection.searchPort, deps.selection.schemas, deps.responder) : null,
|
|
67
|
+
validate: deps.selection ? createPickupPointValidationHandler(deps.selection.port, deps.selection.schemas, deps.responder) : null,
|
|
68
|
+
addresses: deps.addresses ? createCustomerAddressesHandler({ addressBookPort: store.addressBookPort!, addressMutationPort: store.addressMutationPort,
|
|
69
|
+
authenticateUser: deps.addresses.authenticateUser }, deps.addresses.schemas, deps.responder) : null,
|
|
70
|
+
rate: deps.rate ? createAdminShippingRateHandler({ dataPort: store.shippingRateDataPort!, authorizeAdmin: deps.rate.authorizeAdmin,
|
|
71
|
+
resolveAdmin: deps.rate.resolveAdmin }, deps.rate.schemas, deps.responder) : null,
|
|
72
|
+
}, readiness: { ready: true, reason: null }, unsupported: { addressWrites, flatRateSave },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -16,3 +16,4 @@ export type {
|
|
|
16
16
|
CanonicalAddressInput, AddressCanonSource, AddressCanonLocalityCandidate, AddressCanonStreet, AddressCanonPostalCodeLookupRequest, AddressCanonLocalitiesLookupRequest, AddressCanonStreetsLookupRequest, AddressCanonPostalCodeLookupResponse, AddressCanonLocalitiesLookupResponse, AddressCanonStreetsLookupResponse, CustomerAddressKind, CustomerAddress, CustomerOrdererProfile, CustomerAddressesRequest, CustomerAddressesResponse, CustomerAddressUpsertRequest, CustomerAddressDeleteRequest
|
|
17
17
|
} from "./address.js";
|
|
18
18
|
export type { AdminShippingRateResponse, AdminSetShippingRateRequest, AdminSetShippingRateResponse } from "./shippingRate.js";
|
|
19
|
+
export type { ShippingTerms, ShippingPolicyEntry, ShippingPolicy, ShippingSettings } from "./threshold.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Shipping promises selected by an opt-in host policy. Runtime validation is server-only. */
|
|
2
|
+
export interface ShippingTerms {
|
|
3
|
+
version: "shipping-threshold.v1";
|
|
4
|
+
currency: string;
|
|
5
|
+
flatMinor: number;
|
|
6
|
+
freeThresholdMinor: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ShippingPolicyEntry {
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
flatMinor: number;
|
|
11
|
+
freeThresholdMinor: number;
|
|
12
|
+
effectiveFrom: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ShippingPolicy {
|
|
15
|
+
version: "shipping-policy.v1";
|
|
16
|
+
current: ShippingPolicyEntry | null;
|
|
17
|
+
scheduled?: ShippingPolicyEntry | null;
|
|
18
|
+
}
|
|
19
|
+
export interface ShippingSettings {
|
|
20
|
+
legacyFlatMinor: number;
|
|
21
|
+
policy: ShippingPolicy | null;
|
|
22
|
+
}
|
package/src/shipping/index.ts
CHANGED
|
@@ -21,3 +21,6 @@ export { readAddresses, readOrdererProfiles, addressUsedByActiveSubscription, ad
|
|
|
21
21
|
export type { AdminShippingRateResponse, AdminSetShippingRateRequest, AdminSetShippingRateResponse } from "./contracts/shippingRate.js";
|
|
22
22
|
export { createAdminShippingRateHandler } from "./adminShippingRateHandler.js";
|
|
23
23
|
export type { AdminShippingRateHandlerDeps, ShippingRateDataPort, ShippingRateHandlerSchemas } from "./shippingRatePorts.js";
|
|
24
|
+
export { createShippingBindings, type ShippingBindingDependencies, type ShippingBindings } from "./bindings.js";
|
|
25
|
+
export type { ShippingTerms, ShippingPolicyEntry, ShippingPolicy, ShippingSettings } from "./contracts/threshold.js";
|
|
26
|
+
export { shippingTermsSchema, shippingPolicySchema, parseShippingTerms, hasShippingTerms, normalizeShippingPolicy, resolveShippingPolicy, resolveThresholdShipping } from "./thresholdPolicy.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ShippingPolicy, ShippingPolicyEntry, ShippingSettings, ShippingTerms } from "./contracts/threshold.js";
|
|
3
|
+
|
|
4
|
+
const minorSchema = z.number().int().nonnegative().max(2_147_483_647);
|
|
5
|
+
const instantSchema = z.string().datetime({ offset: true }).refine((value) => {
|
|
6
|
+
const [year, month, day] = value.slice(0, 10).split("-").map(Number);
|
|
7
|
+
const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
8
|
+
return day <= days && Number.isFinite(Date.parse(value));
|
|
9
|
+
}, "Invalid calendar date");
|
|
10
|
+
|
|
11
|
+
export const shippingTermsSchema = z.object({
|
|
12
|
+
version: z.literal("shipping-threshold.v1"),
|
|
13
|
+
currency: z.string().regex(/^[A-Z]{3}$/),
|
|
14
|
+
flatMinor: minorSchema,
|
|
15
|
+
freeThresholdMinor: minorSchema,
|
|
16
|
+
}).strict();
|
|
17
|
+
const entrySchema = z.object({
|
|
18
|
+
enabled: z.boolean(), flatMinor: minorSchema, freeThresholdMinor: minorSchema,
|
|
19
|
+
effectiveFrom: instantSchema,
|
|
20
|
+
}).strict();
|
|
21
|
+
export const shippingPolicySchema = z.object({
|
|
22
|
+
version: z.literal("shipping-policy.v1"),
|
|
23
|
+
current: entrySchema.nullable(), scheduled: entrySchema.nullable().optional(),
|
|
24
|
+
}).strict().refine((policy) => !policy.current || !policy.scheduled ||
|
|
25
|
+
Date.parse(policy.current.effectiveFrom) < Date.parse(policy.scheduled.effectiveFrom),
|
|
26
|
+
"Scheduled policy must follow current policy");
|
|
27
|
+
|
|
28
|
+
/** Absence selects legacy behavior; malformed retained promises refuse. */
|
|
29
|
+
export function parseShippingTerms(value: unknown): ShippingTerms | undefined {
|
|
30
|
+
return value === undefined ? undefined : shippingTermsSchema.parse(value);
|
|
31
|
+
}
|
|
32
|
+
export function hasShippingTerms(quote: { context?: { shippingTerms?: unknown } }): boolean {
|
|
33
|
+
return quote.context?.shippingTerms !== undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Resolve effective entries on read, without a scheduled write. */
|
|
36
|
+
export function normalizeShippingPolicy(policy: ShippingPolicy, asOf: Date | string): ShippingPolicy {
|
|
37
|
+
const parsed = shippingPolicySchema.parse(policy);
|
|
38
|
+
const now = new Date(asOf).getTime();
|
|
39
|
+
if (!Number.isFinite(now)) throw new Error("shipping_policy_invalid_as_of");
|
|
40
|
+
const entries = [parsed.current, parsed.scheduled].filter((entry): entry is ShippingPolicyEntry => !!entry);
|
|
41
|
+
const effective = entries.filter((entry) => Date.parse(entry.effectiveFrom) <= now);
|
|
42
|
+
const future = entries.filter((entry) => Date.parse(entry.effectiveFrom) > now);
|
|
43
|
+
if (future.length > 1) throw new Error("shipping_policy_multiple_scheduled_changes");
|
|
44
|
+
return { version: parsed.version, current: effective[effective.length - 1] ?? null, scheduled: future[0] ?? null };
|
|
45
|
+
}
|
|
46
|
+
export function resolveShippingPolicy(settings: ShippingSettings, asOf: Date | string, currency: string): {
|
|
47
|
+
configured: boolean; flatMinor: number; terms?: ShippingTerms;
|
|
48
|
+
} {
|
|
49
|
+
if (settings.policy === null) return { configured: false, flatMinor: minorSchema.parse(settings.legacyFlatMinor) };
|
|
50
|
+
const { current } = normalizeShippingPolicy(settings.policy, asOf);
|
|
51
|
+
if (!current?.enabled) return { configured: true, flatMinor: 0 };
|
|
52
|
+
const terms = shippingTermsSchema.parse({
|
|
53
|
+
version: "shipping-threshold.v1", currency,
|
|
54
|
+
flatMinor: current.flatMinor, freeThresholdMinor: current.freeThresholdMinor,
|
|
55
|
+
});
|
|
56
|
+
return { configured: true, flatMinor: terms.flatMinor, terms };
|
|
57
|
+
}
|
|
58
|
+
/** Only product payable after discounts counts; equality receives free delivery. */
|
|
59
|
+
export function resolveThresholdShipping(terms: ShippingTerms, productPayableMinor: number): {
|
|
60
|
+
grossMinor: number; remainingMinor: number;
|
|
61
|
+
} {
|
|
62
|
+
const payable = minorSchema.parse(productPayableMinor);
|
|
63
|
+
const remainingMinor = Math.max(0, terms.freeThresholdMinor - payable);
|
|
64
|
+
return { grossMinor: remainingMinor > 0 ? terms.flatMinor : 0, remainingMinor };
|
|
65
|
+
}
|