@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +24 -9
  3. package/dist/catalog/adminBundleReadHandler.d.ts +2 -1
  4. package/dist/catalog/adminBundleReadHandler.js +4 -4
  5. package/dist/catalog/bundleCatalogReadPort.d.ts +8 -1
  6. package/dist/catalog/bundleCatalogStore.d.ts +3 -1
  7. package/dist/catalog/bundleCatalogStore.js +5 -1
  8. package/dist/catalog/bundlePriceSelection.d.ts +13 -0
  9. package/dist/catalog/bundlePriceSelection.js +26 -0
  10. package/dist/catalog/contracts/bundleRead.d.ts +18 -0
  11. package/dist/catalog/contracts/draft.js +1 -1
  12. package/dist/catalog/effectiveBundleCatalogQueries.d.ts +8 -0
  13. package/dist/catalog/effectiveBundleCatalogQueries.js +79 -0
  14. package/dist/catalog/effectiveBundleCatalogStore.d.ts +4 -0
  15. package/dist/catalog/effectiveBundleCatalogStore.js +156 -0
  16. package/dist/catalog/index.d.ts +1 -0
  17. package/dist/catalog/index.js +1 -0
  18. package/dist/composition/index.d.ts +2 -0
  19. package/dist/composition/index.js +2 -0
  20. package/dist/composition/inventoryContribution.d.ts +16 -0
  21. package/dist/composition/inventoryContribution.js +17 -0
  22. package/dist/composition/shippingContribution.d.ts +16 -0
  23. package/dist/composition/shippingContribution.js +17 -0
  24. package/dist/inventory/bindings.d.ts +35 -0
  25. package/dist/inventory/bindings.js +45 -0
  26. package/dist/inventory/index.d.ts +1 -0
  27. package/dist/inventory/index.js +1 -0
  28. package/dist/pricing/commercePriceAuthority.d.ts +2 -0
  29. package/dist/pricing/commercePriceAuthority.js +61 -0
  30. package/dist/pricing/commerceQuoteHandler.d.ts +7 -3
  31. package/dist/pricing/commerceQuoteHandler.js +16 -4
  32. package/dist/pricing/dbBackedCommerceQuotePort.d.ts +8 -0
  33. package/dist/pricing/dbBackedCommerceQuotePort.js +46 -29
  34. package/dist/pricing/index.d.ts +2 -1
  35. package/dist/pricing/index.js +1 -1
  36. package/dist/pricing/offerPolicySchemas.d.ts +2 -2
  37. package/dist/pricing/offerPricingSchemas.d.ts +2 -2
  38. package/dist/pricing/pricingPolicyToken.d.ts +2 -2
  39. package/dist/pricing/quoteSchemas.d.ts +18 -18
  40. package/dist/pricing/quoteShippingPromotions.d.ts +20 -0
  41. package/dist/pricing/quoteShippingPromotions.js +1 -0
  42. package/dist/promotions/adminPromotionCodesContracts.d.ts +7 -7
  43. package/dist/promotions/commerceQuoteReadPreparation.d.ts +3 -0
  44. package/dist/promotions/commerceQuoteReadPreparation.js +27 -0
  45. package/dist/promotions/index.d.ts +3 -0
  46. package/dist/promotions/index.js +1 -0
  47. package/dist/promotions/promotionQuoteAcceptance.d.ts +5 -0
  48. package/dist/promotions/promotionQuoteAcceptance.js +12 -12
  49. package/dist/promotions/promotionQuoteBinding.d.ts +4 -2
  50. package/dist/promotions/promotionQuoteBinding.js +5 -3
  51. package/dist/promotions/quoteAcquisitionBinding.d.ts +6 -0
  52. package/dist/promotions/quoteAcquisitionBinding.js +1 -0
  53. package/dist/promotions/quoteAutomaticPromotions.d.ts +3 -1
  54. package/dist/promotions/quoteAutomaticPromotions.js +28 -4
  55. package/dist/promotions/quotePromoDiscounts.d.ts +5 -1
  56. package/dist/promotions/quotePromoDiscounts.js +8 -2
  57. package/dist/promotions/quotePromotionV2.d.ts +7 -6
  58. package/dist/promotions/quotePromotionV2.js +15 -3
  59. package/dist/promotions/quoteShippingPolicy.d.ts +23 -0
  60. package/dist/promotions/quoteShippingPolicy.js +47 -0
  61. package/dist/shipping/bindings.d.ts +57 -0
  62. package/dist/shipping/bindings.js +46 -0
  63. package/dist/shipping/contracts/index.d.ts +1 -0
  64. package/dist/shipping/contracts/threshold.d.ts +22 -0
  65. package/dist/shipping/contracts/threshold.js +1 -0
  66. package/dist/shipping/index.d.ts +3 -0
  67. package/dist/shipping/index.js +2 -0
  68. package/dist/shipping/thresholdPolicy.d.ts +42 -0
  69. package/dist/shipping/thresholdPolicy.js +60 -0
  70. package/package.json +2 -2
  71. package/release-gates.json +136 -134
  72. package/src/catalog/adminBundleReadHandler.ts +6 -4
  73. package/src/catalog/bundleCatalogReadPort.ts +6 -2
  74. package/src/catalog/bundleCatalogStore.ts +5 -0
  75. package/src/catalog/bundlePriceSelection.ts +25 -0
  76. package/src/catalog/contracts/bundleRead.ts +2 -2
  77. package/src/catalog/contracts/draft.ts +1 -1
  78. package/src/catalog/effectiveBundleCatalogQueries.ts +80 -0
  79. package/src/catalog/effectiveBundleCatalogStore.ts +208 -0
  80. package/src/catalog/index.ts +1 -0
  81. package/src/composition/index.ts +2 -0
  82. package/src/composition/inventoryContribution.ts +24 -0
  83. package/src/composition/shippingContribution.ts +24 -0
  84. package/src/inventory/bindings.ts +61 -0
  85. package/src/inventory/index.ts +1 -0
  86. package/src/pricing/commercePriceAuthority.ts +65 -0
  87. package/src/pricing/commerceQuoteHandler.ts +20 -5
  88. package/src/pricing/dbBackedCommerceQuotePort.ts +50 -30
  89. package/src/pricing/index.ts +2 -1
  90. package/src/pricing/quoteShippingPromotions.ts +21 -0
  91. package/src/promotions/commerceQuoteReadPreparation.ts +29 -0
  92. package/src/promotions/index.ts +3 -0
  93. package/src/promotions/promotionQuoteAcceptance.ts +17 -10
  94. package/src/promotions/promotionQuoteBinding.ts +7 -2
  95. package/src/promotions/quoteAcquisitionBinding.ts +7 -0
  96. package/src/promotions/quoteAutomaticPromotions.ts +30 -4
  97. package/src/promotions/quotePromoDiscounts.ts +11 -2
  98. package/src/promotions/quotePromotionV2.ts +20 -4
  99. package/src/promotions/quoteShippingPolicy.ts +61 -0
  100. package/src/shipping/bindings.ts +74 -0
  101. package/src/shipping/contracts/index.ts +1 -0
  102. package/src/shipping/contracts/threshold.ts +22 -0
  103. package/src/shipping/index.ts +3 -0
  104. 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
+ }
@@ -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: input.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
- return evaluateOrderTotalDiscounts({
44
- promoDataPort: legacyPromotionDataPort(input.promoDataPort),
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 candidates = allCandidates.filter(
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
- }): Promise<{ discounts: CommerceQuoteDiscount[]; codeRejections: CommerceCodeRejection[] }> {
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 = (rows as V2Row[])
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(input.promoDataPort),
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
+ }
@@ -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
+ }