@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,17 @@
1
+ /** Structural selected callables keep inventory and catalog declarations independent. */
2
+ export function createInventoryContribution(input) {
3
+ const contribution = {
4
+ manifest: {
5
+ name: "@openlup/platform", version: input.version, kind: "rail",
6
+ module: { id: "inventory", kind: "capability", provides: ["./inventory"], dependsOn: [] },
7
+ emits: [], handles: [], schedules: [], env: [], requiredPorts: Object.keys(input.inventory),
8
+ routes: input.routes.map(route => route.id), requiredSchema: input.requiredSchema,
9
+ },
10
+ handlers: [], schedules: [], routes: input.routes,
11
+ };
12
+ return {
13
+ contributions: [contribution],
14
+ ports: Object.fromEntries(Object.entries(input.inventory).filter(([, fn]) => typeof fn === "function").map(([name, fn]) => [`@openlup/platform/inventory:${name}`, fn])),
15
+ routes: Object.fromEntries(input.routes.map(route => [`@openlup/platform/inventory:${route.id}`, route.handle])),
16
+ };
17
+ }
@@ -0,0 +1,16 @@
1
+ import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
2
+ /** Structural selected callables keep shipping declarations independent. */
3
+ export declare function createShippingContribution(input: {
4
+ version: string;
5
+ requiredSchema: RequiredSchema;
6
+ shipping: Readonly<Record<string, ((...args: never[]) => unknown) | null>>;
7
+ routes: Contribution["routes"];
8
+ }): {
9
+ contributions: Contribution[];
10
+ ports: {
11
+ [k: string]: ((...args: never[]) => unknown) | null;
12
+ };
13
+ routes: {
14
+ [k: string]: (...args: never[]) => unknown;
15
+ };
16
+ };
@@ -0,0 +1,17 @@
1
+ /** Structural selected callables keep shipping declarations independent. */
2
+ export function createShippingContribution(input) {
3
+ const contribution = {
4
+ manifest: {
5
+ name: "@openlup/platform", version: input.version, kind: "rail",
6
+ module: { id: "shipping", kind: "capability", provides: ["./shipping"], dependsOn: [] },
7
+ emits: [], handles: [], schedules: [], env: [], requiredPorts: Object.keys(input.shipping),
8
+ routes: input.routes.map(route => route.id), requiredSchema: input.requiredSchema,
9
+ },
10
+ handlers: [], schedules: [], routes: input.routes,
11
+ };
12
+ return {
13
+ contributions: [contribution],
14
+ ports: Object.fromEntries(Object.entries(input.shipping).filter(([, fn]) => typeof fn === "function").map(([name, fn]) => [`@openlup/platform/shipping:${name}`, fn])),
15
+ routes: Object.fromEntries(input.routes.map(route => [`@openlup/platform/shipping:${route.id}`, route.handle])),
16
+ };
17
+ }
@@ -0,0 +1,35 @@
1
+ import type { z } from "zod";
2
+ import type { InventoryMutationPort, InventoryReadPort } from "./ports.js";
3
+ import type { NewPurchaseStockReadPort } from "./newPurchaseStockAvailability.js";
4
+ export interface InventoryBindingDependencies {
5
+ dataKind: string;
6
+ provider: {
7
+ stockMasterProvider: string;
8
+ providerSchema: z.ZodType<string>;
9
+ };
10
+ executor?: {
11
+ query(text: string, values?: unknown[]): Promise<{
12
+ rows: Record<string, unknown>[];
13
+ }>;
14
+ } | null;
15
+ createManagedStore?: (provider: InventoryBindingDependencies["provider"]) => {
16
+ readPort: InventoryReadPort;
17
+ mutationPort: InventoryMutationPort;
18
+ } | null;
19
+ newPurchaseStockReadPort?: NewPurchaseStockReadPort | null;
20
+ now?: () => string;
21
+ }
22
+ export interface InventoryBindings {
23
+ readPort: InventoryReadPort | null;
24
+ mutationPort: InventoryMutationPort | null;
25
+ newPurchaseStockReadPort: NewPurchaseStockReadPort | null;
26
+ readiness: {
27
+ ready: boolean;
28
+ reason: string | null;
29
+ };
30
+ unsupported: {
31
+ newPurchaseStockRead: string | null;
32
+ };
33
+ }
34
+ /** Select one store only. Factories declare bindings, never discover or activate a host. */
35
+ export declare function createInventoryBindings(deps: InventoryBindingDependencies): InventoryBindings;
@@ -0,0 +1,45 @@
1
+ import { createPostgresAdminInventoryPort } from "./adminInventory.js";
2
+ import { createInventorySubscriptionForecastResponseSchema } from "./schemas.js";
3
+ /** Select one store only. Factories declare bindings, never discover or activate a host. */
4
+ export function createInventoryBindings(deps) {
5
+ const refused = (reason) => ({
6
+ readPort: null, mutationPort: null, newPurchaseStockReadPort: null,
7
+ readiness: { ready: false, reason }, unsupported: { newPurchaseStockRead: reason },
8
+ });
9
+ if (!deps.provider?.providerSchema?.safeParse(deps.provider.stockMasterProvider).success)
10
+ return refused("inventory_provider_mismatch");
11
+ let readPort, mutationPort;
12
+ if (deps.dataKind === "postgres") {
13
+ if (!deps.executor || typeof deps.executor.query !== "function")
14
+ return refused("inventory_executor_required");
15
+ readPort = mutationPort = createPostgresAdminInventoryPort(deps.executor, deps.provider, deps.now);
16
+ }
17
+ else if (deps.dataKind === "supabase") {
18
+ const store = deps.createManagedStore?.(deps.provider);
19
+ if (!store)
20
+ return refused("inventory_managed_store_required");
21
+ ({ readPort, mutationPort } = store);
22
+ }
23
+ else
24
+ return refused("inventory_store_incompatible");
25
+ if (![readPort?.listStock, readPort?.listReservations, readPort?.checkAtp, readPort?.forecastSubscriptions, mutationPort?.adjustStock].every(fn => typeof fn === "function"))
26
+ return refused("inventory_store_incompatible");
27
+ const responseSchema = createInventorySubscriptionForecastResponseSchema(deps.provider.providerSchema);
28
+ const native = deps.dataKind === "postgres";
29
+ const stockRead = native ? null : deps.newPurchaseStockReadPort;
30
+ const newPurchaseStockReadPort = typeof stockRead?.read === "function" ? stockRead : null;
31
+ return {
32
+ readPort: {
33
+ listStock: request => readPort.listStock(request),
34
+ listReservations: request => readPort.listReservations(request),
35
+ checkAtp: request => readPort.checkAtp(request),
36
+ forecastSubscriptions: async (request) => responseSchema.parse(await readPort.forecastSubscriptions(request)),
37
+ },
38
+ mutationPort, newPurchaseStockReadPort,
39
+ readiness: { ready: true, reason: null },
40
+ unsupported: {
41
+ newPurchaseStockRead: native ? "inventory_native_new_purchase_stock_read_unsupported"
42
+ : newPurchaseStockReadPort ? null : "inventory_new_purchase_stock_reader_required",
43
+ },
44
+ };
45
+ }
@@ -11,3 +11,4 @@ export { createPostgresRenewalReservationPort, type AutomaticRenewalInventoryPor
11
11
  export { forecastSubscriptionInventoryShortages, buildSubscriptionInventoryForecast } from "./forecast.js";
12
12
  export { calculateSubscriptionForecastSkus, calculateSubscriptionForecastTotals } from "./subscriptionForecastCalculation.js";
13
13
  export { INVENTORY_MIGRATIONS } from "./sql.js";
14
+ export { createInventoryBindings, type InventoryBindingDependencies, type InventoryBindings } from "./bindings.js";
@@ -8,3 +8,4 @@ export { createPostgresRenewalReservationPort } from "./renewalReservation.js";
8
8
  export { forecastSubscriptionInventoryShortages, buildSubscriptionInventoryForecast } from "./forecast.js";
9
9
  export { calculateSubscriptionForecastSkus, calculateSubscriptionForecastTotals } from "./subscriptionForecastCalculation.js";
10
10
  export { INVENTORY_MIGRATIONS } from "./sql.js";
11
+ export { createInventoryBindings } from "./bindings.js";
@@ -22,5 +22,7 @@ export interface CommercePriceAuthorityReadPort {
22
22
  }
23
23
  /** Provider-neutral strict money algorithm; adapters own only persistence reads and row mapping. */
24
24
  export declare function createCommercePriceAuthorityPort(reads: CommercePriceAuthorityReadPort): CommercePriceAuthorityPort;
25
+ /** Opt-in scheduling using the selected list for concurrent history reads. */
26
+ export declare function createConcurrentCommercePriceAuthorityPort(reads: CommercePriceAuthorityReadPort): CommercePriceAuthorityPort;
25
27
  /** The current money context requires one list; creation order cannot resolve overlap. */
26
28
  export declare function selectUniqueEffectivePriceList(priceLists: readonly TimedAuthorityValue<string>[], atTime: string): string;
@@ -60,6 +60,67 @@ export function createCommercePriceAuthorityPort(reads) {
60
60
  },
61
61
  };
62
62
  }
63
+ /** Opt-in scheduling using the selected list for concurrent history reads. */
64
+ export function createConcurrentCommercePriceAuthorityPort(reads) {
65
+ return {
66
+ async resolvePrice(query) {
67
+ const atTime = query.atTime ?? new Date().toISOString();
68
+ const priceListId = selectUniqueEffectivePriceList(await reads.listPriceLists(query), atTime);
69
+ // Schedule history alongside base reads, retaining base-refusal precedence.
70
+ const policyHistoryRead = query.mode === "subscription" ? Promise.resolve().then(() => reads.listSubscriptionPolicyHistory({ priceListId, regionCode: query.regionCode,
71
+ currency: query.currency, channel: query.channel })).then((value) => ({ value }), (error) => ({ error })) : null;
72
+ const baseCandidates = effective(await reads.listExactBaseEntries({
73
+ variantId: query.variantId,
74
+ priceListId,
75
+ }), atTime);
76
+ if (baseCandidates.length === 0)
77
+ throw new CommercePriceAuthorityError("base_price_not_found");
78
+ if (baseCandidates.length !== 1)
79
+ throw new CommercePriceAuthorityError("base_price_ambiguous");
80
+ if (baseCandidates[0].value.amountKind !== "gross") {
81
+ throw new CommercePriceAuthorityError("base_price_amount_kind_invalid");
82
+ }
83
+ const base = { ...baseCandidates[0].value, resolvedAt: atTime };
84
+ if (query.mode === "one_time")
85
+ return { kind: "one_time", base, unitPriceMinor: base.unitPriceMinor };
86
+ const policyRead = await policyHistoryRead;
87
+ if ("error" in policyRead)
88
+ throw policyRead.error;
89
+ const policyHistory = policyRead.value;
90
+ if (policyHistory.length === 0) {
91
+ const legacyCandidates = effective(await reads.listExactLegacySubscriptionEntries({
92
+ variantId: query.variantId,
93
+ priceListId: base.priceListId,
94
+ }), atTime);
95
+ if (legacyCandidates.length === 0)
96
+ throw new CommercePriceAuthorityError("legacy_subscription_price_not_found");
97
+ if (legacyCandidates.length !== 1)
98
+ throw new CommercePriceAuthorityError("legacy_subscription_price_ambiguous");
99
+ const legacyResolved = { ...legacyCandidates[0].value, resolvedAt: atTime };
100
+ return { kind: "subscription_legacy", base, unitPriceMinor: legacyResolved.unitPriceMinor, legacyResolved };
101
+ }
102
+ const effectivePolicies = effective(policyHistory, atTime);
103
+ if (effectivePolicies.length === 0)
104
+ throw new CommercePriceAuthorityError("subscription_price_policy_not_effective");
105
+ if (effectivePolicies.length !== 1)
106
+ throw new CommercePriceAuthorityError("subscription_price_policy_ambiguous");
107
+ try {
108
+ const policy = await assertSubscriptionPricePolicyDigest(effectivePolicies[0].value);
109
+ return {
110
+ kind: "subscription_policy",
111
+ base,
112
+ unitPriceMinor: deriveSubscriptionUnitPrice(base.unitPriceMinor, policy),
113
+ policy,
114
+ };
115
+ }
116
+ catch (error) {
117
+ throw new CommercePriceAuthorityError(error instanceof SubscriptionPricePolicyDigestError
118
+ ? "subscription_price_policy_digest_mismatch"
119
+ : "subscription_price_policy_invalid");
120
+ }
121
+ },
122
+ };
123
+ }
63
124
  /** The current money context requires one list; creation order cannot resolve overlap. */
64
125
  export function selectUniqueEffectivePriceList(priceLists, atTime) {
65
126
  const candidates = effective(priceLists, atTime);
@@ -1,3 +1,4 @@
1
+ import type { ShippingTerms } from "../shipping/index.js";
1
2
  import type { ResolvedAcquisitionTerms } from "./contracts/acquisitionTerms.js";
2
3
  import type { HttpRequest, HttpResponse } from "../http/index.js";
3
4
  import type { BffResponder } from "../bff/index.js";
@@ -7,6 +8,9 @@ import { type CommerceQuotePort } from "./quotePorts.js";
7
8
  import type { PricingPolicySnapshot } from "./contracts/offerPolicy.js";
8
9
  export interface CommerceQuoteHandlerDeps<Request extends CreateQuoteRequest = CreateQuoteRequest, Response extends CreateQuoteResponse = CreateQuoteResponse> {
9
10
  responder: BffResponder;
11
+ shipping?: {
12
+ terms(quote: Response["quote"]): ShippingTerms | undefined;
13
+ };
10
14
  acquisition?: {
11
15
  terms(quote: Response["quote"]): ResolvedAcquisitionTerms | undefined;
12
16
  };
@@ -25,7 +29,7 @@ export interface CommerceQuoteHandlerDeps<Request extends CreateQuoteRequest = C
25
29
  source: "anonymous" | "resolved_client";
26
30
  }>;
27
31
  resolvePricingPolicy?: (request: Request) => PricingPolicySnapshot | undefined | Promise<PricingPolicySnapshot | undefined>;
28
- createPromotionAcceptance?: (request: Request, quote: Awaited<ReturnType<CommerceQuotePort<Request, Response>["createQuote"]>>["quote"], acquisitionTerms?: ResolvedAcquisitionTerms) => string | null;
29
- honorPromotionAcceptance?: (token: string, request: Request, quote: Awaited<ReturnType<CommerceQuotePort<Request, Response>["createQuote"]>>["quote"], acquisitionTerms?: ResolvedAcquisitionTerms) => boolean;
32
+ createPromotionAcceptance?: (request: Request, quote: Awaited<ReturnType<CommerceQuotePort<Request, Response>["createQuote"]>>["quote"], acquisitionTerms?: ResolvedAcquisitionTerms, shippingTerms?: ShippingTerms) => string | null;
33
+ honorPromotionAcceptance?: (token: string, request: Request, quote: Awaited<ReturnType<CommerceQuotePort<Request, Response>["createQuote"]>>["quote"], acquisitionTerms?: ResolvedAcquisitionTerms, shippingTerms?: ShippingTerms) => boolean;
30
34
  }
31
- export declare function createCommerceQuoteHandler<Request extends CreateQuoteRequest, Response extends CreateQuoteResponse>({ responder, createQuoteRequestSchema, createQuoteResponseSchema, publicCreateQuoteRequestSchema, publicCreateQuoteResponseSchema, quotePort, acquisition, fallbackQuotePort, resolveCustomerEligibility, resolvePricingPolicy, createPromotionAcceptance, honorPromotionAcceptance, }: CommerceQuoteHandlerDeps<Request, Response>): (req: HttpRequest, res: HttpResponse) => Promise<void>;
35
+ export declare function createCommerceQuoteHandler<Request extends CreateQuoteRequest, Response extends CreateQuoteResponse>({ responder, createQuoteRequestSchema, createQuoteResponseSchema, publicCreateQuoteRequestSchema, publicCreateQuoteResponseSchema, quotePort, acquisition, shipping, fallbackQuotePort, resolveCustomerEligibility, resolvePricingPolicy, createPromotionAcceptance, honorPromotionAcceptance, }: CommerceQuoteHandlerDeps<Request, Response>): (req: HttpRequest, res: HttpResponse) => Promise<void>;
@@ -6,7 +6,7 @@ import { CommerceQuoteCatalogReadError } from "./commerceQuoteCatalogReadPort.js
6
6
  function commerceQuoteFailureDetails(stage) {
7
7
  return { reason: "quote_dependency_failed", stage };
8
8
  }
9
- export function createCommerceQuoteHandler({ responder, createQuoteRequestSchema, createQuoteResponseSchema, publicCreateQuoteRequestSchema, publicCreateQuoteResponseSchema, quotePort, acquisition, fallbackQuotePort, resolveCustomerEligibility, resolvePricingPolicy, createPromotionAcceptance, honorPromotionAcceptance, }) {
9
+ export function createCommerceQuoteHandler({ responder, createQuoteRequestSchema, createQuoteResponseSchema, publicCreateQuoteRequestSchema, publicCreateQuoteResponseSchema, quotePort, acquisition, shipping, fallbackQuotePort, resolveCustomerEligibility, resolvePricingPolicy, createPromotionAcceptance, honorPromotionAcceptance, }) {
10
10
  const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = responder;
11
11
  return async function handler(req, res) {
12
12
  if (req.method !== "POST") {
@@ -47,9 +47,21 @@ export function createCommerceQuoteHandler({ responder, createQuoteRequestSchema
47
47
  let quote = canonicalQuote.data;
48
48
  stage = "promotion_acceptance";
49
49
  const acquisitionTerms = acquisition?.terms(quote.quote);
50
- const honoredToken = promotionAcceptanceToken && honorPromotionAcceptance?.(promotionAcceptanceToken, request.data, quote.quote, acquisitionTerms) ? promotionAcceptanceToken : undefined;
51
- const issuedToken = honoredToken ?? createPromotionAcceptance?.(request.data, quote.quote, acquisitionTerms) ?? undefined;
50
+ const shippingTerms = shipping?.terms(quote.quote);
51
+ const honoredToken = promotionAcceptanceToken && (shipping
52
+ ? honorPromotionAcceptance?.(promotionAcceptanceToken, request.data, quote.quote, acquisitionTerms, shippingTerms)
53
+ : honorPromotionAcceptance?.(promotionAcceptanceToken, request.data, quote.quote, acquisitionTerms))
54
+ ? promotionAcceptanceToken : undefined;
55
+ const issuedToken = honoredToken ?? (shipping
56
+ ? createPromotionAcceptance?.(request.data, quote.quote, acquisitionTerms, shippingTerms)
57
+ : createPromotionAcceptance?.(request.data, quote.quote, acquisitionTerms)) ?? undefined;
52
58
  const containsV2Promotion = quote.quote.discounts.some((discount) => discount.promotionEngineVersion === "promotion-engine.v2");
59
+ if (shippingTerms && !issuedToken) {
60
+ sendBffError(res, "CONFLICT", "Shipping quote must be refreshed", {
61
+ details: { feature: "quote", reason: "promotion_acceptance_refresh_required" },
62
+ });
63
+ return;
64
+ }
53
65
  if ((containsV2Promotion || acquisitionTerms) && !issuedToken) {
54
66
  stage = "promotion_fallback";
55
67
  const fallback = fallbackQuotePort
@@ -58,7 +70,7 @@ export function createCommerceQuoteHandler({ responder, createQuoteRequestSchema
58
70
  ...(pricingPolicy ? { pricingPolicy } : {}),
59
71
  }))
60
72
  : null;
61
- if (!fallback?.success || acquisition?.terms(fallback.data.quote) || fallback.data.quote.discounts.some((discount) => discount.promotionEngineVersion === "promotion-engine.v2")) {
73
+ if (!fallback?.success || shipping?.terms(fallback.data.quote) || acquisition?.terms(fallback.data.quote) || fallback.data.quote.discounts.some((discount) => discount.promotionEngineVersion === "promotion-engine.v2")) {
62
74
  sendBffError(res, "CONFLICT", "Promotion quote must be refreshed", {
63
75
  details: { feature: "quote", reason: "promotion_acceptance_refresh_required" },
64
76
  });
@@ -1,3 +1,6 @@
1
+ import type { ShippingTerms } from "../shipping/index.js";
2
+ import type { ResolvedAcquisitionTerms } from "./contracts/acquisitionTerms.js";
3
+ import type { QuoteShippingPromotionInput, QuoteShippingPromotionResult } from "./quoteShippingPromotions.js";
1
4
  import type { PricingResolverPort } from "@openlup/core/pricing";
2
5
  import type { CreateQuoteResponse, PricingPolicySnapshot, CreateQuoteRequest, CommerceQuoteContext, CommerceQuoteLine, CommerceTaxProfile, CommerceQuoteDiscount, CommerceCodeRejection, CommerceCodeRejectionDetail, CartLineInput } from "./contracts/index.js";
3
6
  import { type CreateQuoteOptions } from "./quotePorts.js";
@@ -21,6 +24,7 @@ export interface DbBackedCommerceQuotePortDeps<Request extends CreateQuoteReques
21
24
  currency: string;
22
25
  now?: () => string;
23
26
  shippingFlatMinor?: () => Promise<number>;
27
+ resolveQuoteShippingPromotions?: (input: QuoteShippingPromotionInput<Request>) => Promise<QuoteShippingPromotionResult>;
24
28
  hasPromotionCodePort: boolean;
25
29
  lineContext(line: CartLineInput, item: Item): LineContext;
26
30
  quoteContext(request: Request, lines: Array<LineContext & {
@@ -53,6 +57,8 @@ export declare function createDbBackedCommerceQuotePort<Request extends CreateQu
53
57
  quote: Omit<CreateQuoteResponse["quote"], "context"> & {
54
58
  context: Context & {
55
59
  pricingPolicy?: PricingPolicySnapshot;
60
+ shippingTerms?: ShippingTerms;
61
+ acquisitionTerms?: ResolvedAcquisitionTerms;
56
62
  };
57
63
  };
58
64
  }>;
@@ -60,6 +66,8 @@ export declare function createDbBackedCommerceQuotePort<Request extends CreateQu
60
66
  quote: Omit<CreateQuoteResponse["quote"], "context"> & {
61
67
  context: Context & {
62
68
  pricingPolicy?: PricingPolicySnapshot;
69
+ shippingTerms?: ShippingTerms;
70
+ acquisitionTerms?: ResolvedAcquisitionTerms;
63
71
  };
64
72
  };
65
73
  }>;
@@ -7,7 +7,7 @@ import { assertSupportedCommerceTaxProfile } from "./taxProfileGuard.js";
7
7
  import { catalogFactsForQuoteLine, projectPublicQuoteSnapshot } from "./catalogFactsProvenance.js";
8
8
  const STRICT_D1_COMMERCE_CHANNEL = "D2C";
9
9
  export function createDbBackedCommerceQuotePort(deps) {
10
- const { taxProfile, supportedTaxProfiles, regionCode, currency, now = () => new Date().toISOString(), quoteCatalogReadPort, isStrictD1Quote, pricingResolverPort, commercePriceAuthorityPort, shippingFlatMinor, resolveAutomaticQuotePromotions, applyPromotionCodesV2, hasPromotionCodePort, lineContext, quoteContext, } = deps;
10
+ const { taxProfile, supportedTaxProfiles, regionCode, currency, now = () => new Date().toISOString(), quoteCatalogReadPort, isStrictD1Quote, pricingResolverPort, commercePriceAuthorityPort, shippingFlatMinor, resolveAutomaticQuotePromotions, applyPromotionCodesV2, hasPromotionCodePort, lineContext, quoteContext, resolveQuoteShippingPromotions, } = deps;
11
11
  assertSupportedCommerceTaxProfile(taxProfile, supportedTaxProfiles);
12
12
  const money = moneyIn(currency);
13
13
  async function createServerAuthoritativeQuote(request, options) {
@@ -129,33 +129,49 @@ export function createDbBackedCommerceQuotePort(deps) {
129
129
  const referenceProductMinor = sum(pricedLines.map((entry) => entry.referenceGrossMinor));
130
130
  const netTotalMinor = sum(lines.map((line) => line.tax.netAmount.amountMinor));
131
131
  const taxTotalMinor = sum(lines.map((line) => line.tax.vatAmount.amountMinor));
132
- // Shipping is a flat per-order gross from settings; absent settings port ⇒ no
133
- // shipping (byte-identical to pre-shipping quotes). Resolved before promo eval so
134
- // the free-shipping lane has a real amount to zero.
135
- const shippingGrossMinor = shippingFlatMinor
136
- ? await shippingFlatMinor()
137
- : 0;
138
- const automaticPromotionResult = await resolveAutomaticQuotePromotions({
139
- request,
140
- options,
141
- regionCode,
142
- referenceProductMinor,
143
- subtotalGrossMinor,
144
- shippingGrossMinor,
145
- atTime,
146
- });
147
- const promotionResult = await applyPromotionCodesV2({
148
- preserveLegacyDiscountsWhenNoCandidates: options?.pricingPolicy?.promotionEngineVersion !== PROMOTION_ENGINE_V2,
149
- clientId: options?.clientId ?? null,
150
- mode: request.mode,
151
- promoCodes: request.promoCodes,
152
- referenceProductMinor: sum(pricedLines.map((entry) => entry.referenceGrossMinor)),
153
- subtotalGrossMinor,
154
- shippingGrossMinor,
155
- discounts: automaticPromotionResult.discounts,
156
- codeRejections: automaticPromotionResult.codeRejections,
157
- atTime,
158
- });
132
+ let shippingGrossMinor;
133
+ let promotionResult;
134
+ let hasShipping;
135
+ let shippingTerms;
136
+ let acquisitionTerms;
137
+ if (resolveQuoteShippingPromotions) {
138
+ const resolved = await resolveQuoteShippingPromotions({ request, options, regionCode, currency, referenceProductMinor, subtotalGrossMinor, atTime });
139
+ shippingGrossMinor = resolved.shippingGrossMinor;
140
+ promotionResult = resolved;
141
+ hasShipping = resolved.hasShipping;
142
+ shippingTerms = resolved.shippingTerms;
143
+ acquisitionTerms = resolved.acquisitionTerms;
144
+ }
145
+ else {
146
+ // Shipping is a flat per-order gross from settings; absent settings port ⇒ no
147
+ // shipping (byte-identical to pre-shipping quotes). Resolved before promo eval so
148
+ // the free-shipping lane has a real amount to zero.
149
+ shippingGrossMinor = shippingFlatMinor
150
+ ? await shippingFlatMinor()
151
+ : 0;
152
+ const automaticPromotionResult = await resolveAutomaticQuotePromotions({
153
+ request,
154
+ options,
155
+ regionCode,
156
+ referenceProductMinor,
157
+ subtotalGrossMinor,
158
+ shippingGrossMinor,
159
+ atTime,
160
+ });
161
+ promotionResult = await applyPromotionCodesV2({
162
+ preserveLegacyDiscountsWhenNoCandidates: options?.pricingPolicy?.promotionEngineVersion !== PROMOTION_ENGINE_V2,
163
+ clientId: options?.clientId ?? null,
164
+ mode: request.mode,
165
+ promoCodes: request.promoCodes,
166
+ referenceProductMinor: sum(pricedLines.map((entry) => entry.referenceGrossMinor)),
167
+ subtotalGrossMinor,
168
+ shippingGrossMinor,
169
+ discounts: automaticPromotionResult.discounts,
170
+ codeRejections: automaticPromotionResult.codeRejections,
171
+ atTime,
172
+ });
173
+ hasShipping = shippingFlatMinor != null;
174
+ }
159
175
  const { discounts, codeRejections, codeRejectionDetails } = promotionResult;
160
176
  // Two lanes: order_total/line discounts reduce the subtotal; a shipping discount
161
177
  // (free-shipping promo) reduces the shipping gross. They are summed and validated
@@ -169,7 +185,6 @@ export function createDbBackedCommerceQuotePort(deps) {
169
185
  const { netMinor: discountNetMinor, vatMinor: discountVatMinor } = splitIncludedVat(orderDiscountMinor, taxProfile.vatRateBps);
170
186
  const ship = splitIncludedVat(shippingGrossMinor, taxProfile.vatRateBps);
171
187
  const shipDiscount = splitIncludedVat(shippingDiscountMinor, taxProfile.vatRateBps);
172
- const hasShipping = shippingFlatMinor != null;
173
188
  const shippingFields = hasShipping
174
189
  ? {
175
190
  shippingGross: money(shippingGrossMinor),
@@ -188,6 +203,8 @@ export function createDbBackedCommerceQuotePort(deps) {
188
203
  pricingComponents: quoteComponents,
189
204
  context: {
190
205
  ...context(),
206
+ ...(resolveQuoteShippingPromotions && acquisitionTerms ? { acquisitionTerms } : {}),
207
+ ...(resolveQuoteShippingPromotions && shippingTerms ? { shippingTerms } : {}),
191
208
  ...(options?.pricingPolicy ? { pricingPolicy: options.pricingPolicy } : {}),
192
209
  },
193
210
  subtotalGross: money(subtotalGrossMinor),
@@ -1,7 +1,7 @@
1
1
  export * from "./contracts/index.js";
2
2
  export { CommercePriceAuthorityError } from "./ports.js";
3
3
  export type { CommercePriceAuthorityPort } from "./ports.js";
4
- export { createCommercePriceAuthorityPort, selectUniqueEffectivePriceList } from "./commercePriceAuthority.js";
4
+ export { createCommercePriceAuthorityPort, createConcurrentCommercePriceAuthorityPort, selectUniqueEffectivePriceList } from "./commercePriceAuthority.js";
5
5
  export type { CommercePriceAuthorityReadPort, TimedAuthorityValue } from "./commercePriceAuthority.js";
6
6
  export { subscriptionPricePolicySemanticsSchema, subscriptionPricePolicyCanonicalInputSchema, subscriptionPricePolicyRevisionSchema, SubscriptionPricePolicyDigestError, canonicalSubscriptionPricePolicyTuple, digestSubscriptionPricePolicy, assertSubscriptionPricePolicyDigest, deriveSubscriptionUnitPrice } from "./subscriptionPricePolicy.js";
7
7
  export { offerPolicyVersionSchema, promotionEngineVersionSchema, pricingPolicyRequestSchema, pricingPolicySnapshotSchema } from "./offerPolicySchemas.js";
@@ -49,3 +49,4 @@ export * from "./acquisitionOfferPricingSchemas.js";
49
49
  export * from "./acquisitionOfferPricing.js";
50
50
  export * from "./acquisitionOfferPricingHandler.js";
51
51
  export * from "./acquisitionReadiness.js";
52
+ export type { QuoteShippingPromotionInput, QuoteShippingPromotionResult } from "./quoteShippingPromotions.js";
@@ -1,6 +1,6 @@
1
1
  export * from "./contracts/index.js";
2
2
  export { CommercePriceAuthorityError } from "./ports.js";
3
- export { createCommercePriceAuthorityPort, selectUniqueEffectivePriceList } from "./commercePriceAuthority.js";
3
+ export { createCommercePriceAuthorityPort, createConcurrentCommercePriceAuthorityPort, selectUniqueEffectivePriceList } from "./commercePriceAuthority.js";
4
4
  export { subscriptionPricePolicySemanticsSchema, subscriptionPricePolicyCanonicalInputSchema, subscriptionPricePolicyRevisionSchema, SubscriptionPricePolicyDigestError, canonicalSubscriptionPricePolicyTuple, digestSubscriptionPricePolicy, assertSubscriptionPricePolicyDigest, deriveSubscriptionUnitPrice } from "./subscriptionPricePolicy.js";
5
5
  export { offerPolicyVersionSchema, promotionEngineVersionSchema, pricingPolicyRequestSchema, pricingPolicySnapshotSchema } from "./offerPolicySchemas.js";
6
6
  export { createPricingPolicyToken, verifyPricingPolicyToken, resolvePricingPolicy } from "./pricingPolicyToken.js";
@@ -4,8 +4,8 @@ export declare const offerPolicyVersionSchema: z.ZodEnum<{
4
4
  "commerce.offer-policy.v2": "commerce.offer-policy.v2";
5
5
  }>;
6
6
  export declare const promotionEngineVersionSchema: z.ZodEnum<{
7
- "promotion-engine.v2": "promotion-engine.v2";
8
7
  "promotion-engine.v1": "promotion-engine.v1";
8
+ "promotion-engine.v2": "promotion-engine.v2";
9
9
  }>;
10
10
  /** Client capability plus an opaque server-signed assignment. Never a version selector. */
11
11
  export declare const pricingPolicyRequestSchema: z.ZodObject<{
@@ -18,8 +18,8 @@ export declare const pricingPolicySnapshotSchema: z.ZodObject<{
18
18
  "commerce.offer-policy.v2": "commerce.offer-policy.v2";
19
19
  }>;
20
20
  promotionEngineVersion: z.ZodEnum<{
21
- "promotion-engine.v2": "promotion-engine.v2";
22
21
  "promotion-engine.v1": "promotion-engine.v1";
22
+ "promotion-engine.v2": "promotion-engine.v2";
23
23
  }>;
24
24
  pricingPolicyToken: z.ZodOptional<z.ZodString>;
25
25
  }, z.core.$strict>;
@@ -99,8 +99,8 @@ export declare function createCommerceOfferPricingSchemas<ScopeV1 extends string
99
99
  "commerce.offer-policy.v2": "commerce.offer-policy.v2";
100
100
  }>;
101
101
  promotionEngineVersion: z.ZodEnum<{
102
- "promotion-engine.v2": "promotion-engine.v2";
103
102
  "promotion-engine.v1": "promotion-engine.v1";
103
+ "promotion-engine.v2": "promotion-engine.v2";
104
104
  }>;
105
105
  pricingPolicyToken: z.ZodOptional<z.ZodString>;
106
106
  }, z.core.$strict>;
@@ -241,8 +241,8 @@ export declare function createCommerceOfferPricingSchemas<ScopeV1 extends string
241
241
  "commerce.offer-policy.v2": "commerce.offer-policy.v2";
242
242
  }>;
243
243
  promotionEngineVersion: z.ZodEnum<{
244
- "promotion-engine.v2": "promotion-engine.v2";
245
244
  "promotion-engine.v1": "promotion-engine.v1";
245
+ "promotion-engine.v2": "promotion-engine.v2";
246
246
  }>;
247
247
  pricingPolicyToken: z.ZodOptional<z.ZodString>;
248
248
  }, z.core.$strict>;
@@ -29,11 +29,11 @@ export declare function resolvePricingPolicy(input: {
29
29
  promotionEngineVersion: PromotionEngineVersion;
30
30
  } | {
31
31
  offerPolicyVersion: "commerce.offer-policy.v1" | "commerce.offer-policy.v2";
32
- promotionEngineVersion: "promotion-engine.v2" | "promotion-engine.v1";
32
+ promotionEngineVersion: "promotion-engine.v1" | "promotion-engine.v2";
33
33
  pricingPolicyToken?: undefined;
34
34
  } | {
35
35
  offerPolicyVersion: "commerce.offer-policy.v1" | "commerce.offer-policy.v2";
36
- promotionEngineVersion: "promotion-engine.v2" | "promotion-engine.v1";
36
+ promotionEngineVersion: "promotion-engine.v1" | "promotion-engine.v2";
37
37
  pricingPolicyToken: string;
38
38
  };
39
39
  export {};