@openlup/platform 0.18.0 → 0.20.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 (105) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +34 -11
  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 +62 -0
  62. package/dist/shipping/bindings.js +56 -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 +17 -0
  67. package/dist/shipping/index.js +7 -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/sql/20261021200000_shipping_flat_rate_seed.sql +1 -0
  73. package/src/catalog/adminBundleReadHandler.ts +6 -4
  74. package/src/catalog/bundleCatalogReadPort.ts +6 -2
  75. package/src/catalog/bundleCatalogStore.ts +5 -0
  76. package/src/catalog/bundlePriceSelection.ts +25 -0
  77. package/src/catalog/contracts/bundleRead.ts +2 -2
  78. package/src/catalog/contracts/draft.ts +1 -1
  79. package/src/catalog/effectiveBundleCatalogQueries.ts +80 -0
  80. package/src/catalog/effectiveBundleCatalogStore.ts +208 -0
  81. package/src/catalog/index.ts +1 -0
  82. package/src/composition/index.ts +2 -0
  83. package/src/composition/inventoryContribution.ts +24 -0
  84. package/src/composition/shippingContribution.ts +24 -0
  85. package/src/inventory/bindings.ts +61 -0
  86. package/src/inventory/index.ts +1 -0
  87. package/src/pricing/commercePriceAuthority.ts +65 -0
  88. package/src/pricing/commerceQuoteHandler.ts +20 -5
  89. package/src/pricing/dbBackedCommerceQuotePort.ts +50 -30
  90. package/src/pricing/index.ts +2 -1
  91. package/src/pricing/quoteShippingPromotions.ts +21 -0
  92. package/src/promotions/commerceQuoteReadPreparation.ts +29 -0
  93. package/src/promotions/index.ts +3 -0
  94. package/src/promotions/promotionQuoteAcceptance.ts +17 -10
  95. package/src/promotions/promotionQuoteBinding.ts +7 -2
  96. package/src/promotions/quoteAcquisitionBinding.ts +7 -0
  97. package/src/promotions/quoteAutomaticPromotions.ts +30 -4
  98. package/src/promotions/quotePromoDiscounts.ts +11 -2
  99. package/src/promotions/quotePromotionV2.ts +20 -4
  100. package/src/promotions/quoteShippingPolicy.ts +61 -0
  101. package/src/shipping/bindings.ts +90 -0
  102. package/src/shipping/contracts/index.ts +1 -0
  103. package/src/shipping/contracts/threshold.ts +22 -0
  104. package/src/shipping/index.ts +9 -0
  105. package/src/shipping/thresholdPolicy.ts +65 -0
@@ -0,0 +1,208 @@
1
+ import type { PgQueryExecutor } from "../data/index.js";
2
+ import { assertPrimaryUnitGtinAuthority, type CatalogSkuIdentifier } from "./contracts/foundation.js";
3
+ import { BundleCatalogReadError, type BundleCatalogReadPort, type BundleComponentRow, type BundleSummary, type GetBundleInput } from "./bundleCatalogReadPort.js";
4
+ import { uniqueBundlePrice, selectEffectiveBundleTargetPrice } from "./bundlePriceSelection.js";
5
+ import { COUNT_SQL } from "./bundleCatalogQueries.js";
6
+ import { LIST_SQL, DETAIL_SQL, COMPONENT_SQL, FEED_COMPONENT_SQL, PRICE_SQL, OWN_LIST_SQL, CURRENCY_LIST_SQL, FEED_SQL } from "./effectiveBundleCatalogQueries.js";
7
+
8
+ interface BundleRow {
9
+ id: string;
10
+ code: string;
11
+ title: string;
12
+ status: string;
13
+ fulfillment_mode: string;
14
+ updated_at: string;
15
+ component_count: string | number;
16
+ has_active_target_price: boolean;
17
+ composition_constraint: Record<string, unknown> | null;
18
+ metadata: Record<string, unknown> | null;
19
+ }
20
+
21
+ interface ComponentRow {
22
+ bundle_id: string;
23
+ sku: string;
24
+ title: string | null;
25
+ product_slug: string;
26
+ variant_id: string;
27
+ quantity: number;
28
+ is_addon: boolean;
29
+ sort_order: number;
30
+ reference_unit_price_minor: number | null;
31
+ reference_price_count: number | string;
32
+ sku_status: string;
33
+ product_status: string;
34
+ net_weight_g: number;
35
+ sellable_standalone: boolean;
36
+ sellable_in_subscription: boolean;
37
+ document_ready: boolean;
38
+ identifiers: CatalogSkuIdentifier[];
39
+ }
40
+
41
+ interface PriceRow {
42
+ price_list_id: string;
43
+ mode: string;
44
+ target_price_minor: number;
45
+ currency: string;
46
+ amount_kind: string;
47
+ active: boolean;
48
+ valid_from: string;
49
+ valid_to: string | null;
50
+ }
51
+
52
+ interface ActiveBundleRow {
53
+ id: string;
54
+ code: string;
55
+ title: string;
56
+ fulfillment_mode: string;
57
+ mode: string;
58
+ target_price_minor: number;
59
+ price_list_id: string;
60
+ currency: string;
61
+ region_code: string;
62
+ }
63
+
64
+
65
+ /** Selected only by the explicit effective-authority option. */
66
+ export function createEffectiveBundleCatalogStore(executor: PgQueryExecutor, incumbent: BundleCatalogReadPort): BundleCatalogReadPort {
67
+ async function rows<T>(sql: string, values: unknown[]): Promise<T[]> {
68
+ const result = await executor.query(sql, values).catch((error: unknown) => {
69
+ throw new BundleCatalogReadError(error instanceof Error ? error.message : String(error));
70
+ });
71
+ return (result.rows ?? []) as unknown as T[];
72
+ }
73
+ async function selectPriceList(bundleId: string, input: GetBundleInput): Promise<{ id: string; currency: string } | null> {
74
+ const atTime = input.atTime ?? new Date().toISOString(), mode = input.mode ?? "one_time";
75
+ const own = input.currency ? [] : await rows<{ id: string; currency: string; region_code: string; mode: string }>(OWN_LIST_SQL, [bundleId, mode, atTime]);
76
+ const chosenPrice = uniqueBundlePrice(own, mode), chosen = chosenPrice ? [chosenPrice] : [];
77
+ const currency = input.currency ?? chosen[0]?.currency;
78
+ if (!currency) return null;
79
+ const candidates = await rows<{ id: string; currency: string }>(CURRENCY_LIST_SQL, [currency, input.regionCode ?? chosen[0]?.region_code ?? null, atTime]);
80
+ if (candidates.length > 1) throw new BundleCatalogReadError("bundle_price_list_ambiguous");
81
+ if (chosen[0] && candidates[0] && chosen[0].id !== candidates[0].id) throw new BundleCatalogReadError("bundle_price_list_changed");
82
+ return candidates[0] ?? null;
83
+ }
84
+ return {
85
+ ...incumbent,
86
+ async listBundles(input) {
87
+ const filter = [input.status, input.query ?? null];
88
+ const [page, counted] = await Promise.all([
89
+ rows<BundleRow>(LIST_SQL, [...filter, input.limit, input.offset]),
90
+ rows<{ total: string | number }>(COUNT_SQL, filter),
91
+ ]);
92
+ return { bundles: page.map(toSummary), total: Number(counted[0]?.total ?? page.length) };
93
+ },
94
+ async getBundle(input) {
95
+ const bundle = (await rows<BundleRow>(DETAIL_SQL, [input.code]))[0];
96
+ if (!bundle) return null;
97
+ const atTime = input.atTime ?? new Date().toISOString();
98
+ const list = await selectPriceList(bundle.id, { ...input, atTime });
99
+ const [componentRows, priceRows] = await Promise.all([
100
+ rows<ComponentRow>(COMPONENT_SQL, [bundle.id, list?.id ?? null, atTime, input.mode ?? "one_time"]).then(rows => rows.map(toComponent)),
101
+ rows<PriceRow>(PRICE_SQL, [bundle.id]),
102
+ ]);
103
+ return {
104
+ ...toSummary(bundle), compositionConstraint: toConstraint(bundle.composition_constraint), metadata: bundle.metadata ?? {},
105
+ components: componentRows, prices: priceRows.map(toPrice),
106
+ resolvedTargetPrice: selectEffectiveBundleTargetPrice(priceRows.map(row => ({ price: toPrice(row), priceListId: row.price_list_id })),
107
+ { priceListId: list?.id ?? null, currency: list?.currency ?? null, mode: input.mode ?? "one_time", atTime }),
108
+ resolvedCurrency: list?.currency ?? null, resolvedPriceListId: list?.id ?? null,
109
+ };
110
+ },
111
+ async listActiveBundleCompositions(input = {}) {
112
+ const atTime = input.atTime ?? new Date().toISOString(), mode = input.mode ?? "one_time";
113
+ const feed = await rows<ActiveBundleRow>(FEED_SQL, [input.currency ?? null, input.regionCode ?? null, atTime, mode]);
114
+ const selected = new Map<string, ActiveBundleRow>();
115
+ const listReads = new Map<string, Promise<{ id: string }[]>>();
116
+ for (const row of feed) {
117
+ const same = feed.filter(candidate => candidate.id === row.id);
118
+ const candidate = uniqueBundlePrice(same, mode)!;
119
+ const key = `${row.currency}:${row.region_code}`;
120
+ if (!listReads.has(key)) listReads.set(key, rows<{ id: string }>(CURRENCY_LIST_SQL, [row.currency, row.region_code ?? null, atTime]));
121
+ const lists = await listReads.get(key)!;
122
+ if (lists.length > 1) throw new BundleCatalogReadError("bundle_price_list_ambiguous");
123
+ if (lists[0]?.id !== row.price_list_id) throw new BundleCatalogReadError("bundle_price_list_changed");
124
+ selected.set(row.id, candidate);
125
+ }
126
+ if (!selected.size) return [];
127
+ const chosen = [...selected.values()];
128
+ const source = await rows<ComponentRow>(FEED_COMPONENT_SQL, [chosen.map(row => row.id), chosen.map(row => row.price_list_id), atTime, mode]);
129
+ const byBundle = groupComponents(source);
130
+ const sellable = chosen.filter(row => {
131
+ const components = source.filter(component => component.bundle_id === row.id);
132
+ return components.length > 0 && components.every(component => componentReady(component, mode));
133
+ });
134
+ return sellable.map(row => ({ code: row.code, title: row.title, fulfillmentMode: row.fulfillment_mode,
135
+ currency: row.currency, targetPriceMinor: row.target_price_minor, mode: row.mode, components: byBundle.get(row.id) ?? [] }));
136
+ },
137
+ };
138
+ }
139
+
140
+ function groupComponents(rows: readonly ComponentRow[]): Map<string, BundleComponentRow[]> {
141
+ const grouped = new Map<string, BundleComponentRow[]>();
142
+ for (const row of rows) {
143
+ const bucket = grouped.get(row.bundle_id) ?? [];
144
+ bucket.push(toComponent(row));
145
+ grouped.set(row.bundle_id, bucket);
146
+ }
147
+ return grouped;
148
+ }
149
+
150
+ function toComponent(row: ComponentRow): BundleComponentRow {
151
+ if (Number(row.reference_price_count) > 1) throw new BundleCatalogReadError("bundle_price_ambiguous");
152
+ return {
153
+ sku: row.sku,
154
+ title: row.title ?? null,
155
+ productSlug: row.product_slug,
156
+ variantId: row.variant_id,
157
+ quantity: Number(row.quantity),
158
+ isAddon: row.is_addon === true,
159
+ sortOrder: Number(row.sort_order),
160
+ referenceUnitPriceMinor:
161
+ row.reference_unit_price_minor === null || row.reference_unit_price_minor === undefined
162
+ ? null
163
+ : Number(row.reference_unit_price_minor),
164
+ };
165
+ }
166
+
167
+ function toSummary(row: BundleRow): BundleSummary {
168
+ return {
169
+ code: row.code,
170
+ title: row.title,
171
+ status: row.status,
172
+ fulfillmentMode: row.fulfillment_mode,
173
+ componentCount: Number(row.component_count ?? 0),
174
+ hasActiveTargetPrice: row.has_active_target_price === true,
175
+ updatedAt: row.updated_at,
176
+ };
177
+ }
178
+
179
+ function toPrice(row: PriceRow) {
180
+ return {
181
+ mode: row.mode,
182
+ targetPriceMinor: Number(row.target_price_minor),
183
+ currency: row.currency,
184
+ amountKind: row.amount_kind,
185
+ active: row.active === true,
186
+ validFrom: row.valid_from,
187
+ validTo: row.valid_to ?? null,
188
+ };
189
+ }
190
+
191
+ /** An empty envelope is stored as `{}`; that is "unconstrained", not a constraint. */
192
+ function toConstraint(value: Record<string, unknown> | null) {
193
+ if (!value || typeof value.kind !== "string" || value.kind.trim() === "") return null;
194
+ return {
195
+ kind: value.kind,
196
+ version: typeof value.version === "number" ? value.version : 0,
197
+ data: (value.data ?? {}) as Record<string, unknown>,
198
+ };
199
+ }
200
+
201
+ function componentReady(row: ComponentRow, mode: string): boolean {
202
+ try { assertPrimaryUnitGtinAuthority(row.identifiers); } catch { return false; }
203
+ return row.sku_status === "active" && row.product_status === "active" && row.document_ready === true
204
+ && Number.isSafeInteger(row.net_weight_g) && row.net_weight_g > 0
205
+ && (mode === "subscription" ? row.sellable_in_subscription : row.sellable_standalone)
206
+ && row.reference_unit_price_minor !== null && Number.isSafeInteger(Number(row.reference_unit_price_minor))
207
+ && Number(row.reference_unit_price_minor) > 0;
208
+ }
@@ -41,3 +41,4 @@ export { BUNDLE_MIGRATIONS, CATALOG_MIGRATIONS } from "./sql.js";
41
41
  export { SUMMARY_COLUMNS, LIST_FILTER, LIST_SQL, COUNT_SQL, DETAIL_SQL, REFERENCE_PRICE, COMPONENT_SQL, FEED_COMPONENT_SQL, PRICE_SQL, OWN_LIST_SQL, CURRENCY_LIST_SQL, FEED_SQL, HISTORY_SQL, HISTORY_COUNT_SQL } from "./bundleCatalogQueries.js";
42
42
  export { createPostgresCatalogSkuEnvelopeReadPort } from "./catalogSkuEnvelope.js";
43
43
  export { createPostgresCatalogDraftStore } from "./catalogDraftStore.js";
44
+ export { selectEffectiveBundleTargetPrice } from "./bundlePriceSelection.js";
@@ -16,3 +16,5 @@ export {
16
16
  type ServeNodeResult,
17
17
  } from "./nodeHost.js";
18
18
  export { createCatalogContribution, type CatalogContributionInput } from "./catalogContribution.js";
19
+ export { createInventoryContribution } from "./inventoryContribution.js";
20
+ export { createShippingContribution } from "./shippingContribution.js";
@@ -0,0 +1,24 @@
1
+ import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
2
+
3
+ /** Structural selected callables keep inventory and catalog declarations independent. */
4
+ export function createInventoryContribution(input: {
5
+ version: string;
6
+ requiredSchema: RequiredSchema;
7
+ inventory: Readonly<Record<string, ((...args: never[]) => unknown) | null>>;
8
+ routes: Contribution["routes"];
9
+ }) {
10
+ const contribution: Contribution = {
11
+ manifest: {
12
+ name: "@openlup/platform", version: input.version, kind: "rail",
13
+ module: { id: "inventory", kind: "capability", provides: ["./inventory"], dependsOn: [] },
14
+ emits: [], handles: [], schedules: [], env: [], requiredPorts: Object.keys(input.inventory),
15
+ routes: input.routes.map(route => route.id), requiredSchema: input.requiredSchema,
16
+ },
17
+ handlers: [], schedules: [], routes: input.routes,
18
+ };
19
+ return {
20
+ contributions: [contribution],
21
+ ports: Object.fromEntries(Object.entries(input.inventory).filter(([, fn]) => typeof fn === "function").map(([name, fn]) => [`@openlup/platform/inventory:${name}`, fn])),
22
+ routes: Object.fromEntries(input.routes.map(route => [`@openlup/platform/inventory:${route.id}`, route.handle])),
23
+ };
24
+ }
@@ -0,0 +1,24 @@
1
+ import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
2
+
3
+ /** Structural selected callables keep shipping declarations independent. */
4
+ export function createShippingContribution(input: {
5
+ version: string;
6
+ requiredSchema: RequiredSchema;
7
+ shipping: Readonly<Record<string, ((...args: never[]) => unknown) | null>>;
8
+ routes: Contribution["routes"];
9
+ }) {
10
+ const contribution: Contribution = {
11
+ manifest: {
12
+ name: "@openlup/platform", version: input.version, kind: "rail",
13
+ module: { id: "shipping", kind: "capability", provides: ["./shipping"], dependsOn: [] },
14
+ emits: [], handles: [], schedules: [], env: [], requiredPorts: Object.keys(input.shipping),
15
+ routes: input.routes.map(route => route.id), requiredSchema: input.requiredSchema,
16
+ },
17
+ handlers: [], schedules: [], routes: input.routes,
18
+ };
19
+ return {
20
+ contributions: [contribution],
21
+ ports: Object.fromEntries(Object.entries(input.shipping).filter(([, fn]) => typeof fn === "function").map(([name, fn]) => [`@openlup/platform/shipping:${name}`, fn])),
22
+ routes: Object.fromEntries(input.routes.map(route => [`@openlup/platform/shipping:${route.id}`, route.handle])),
23
+ };
24
+ }
@@ -0,0 +1,61 @@
1
+ import type { z } from "zod";
2
+ import { createPostgresAdminInventoryPort } from "./adminInventory.js";
3
+ import type { InventoryMutationPort, InventoryReadPort } from "./ports.js";
4
+ import type { NewPurchaseStockReadPort } from "./newPurchaseStockAvailability.js";
5
+ import { createInventorySubscriptionForecastResponseSchema } from "./schemas.js";
6
+
7
+ export interface InventoryBindingDependencies {
8
+ dataKind: string;
9
+ provider: { stockMasterProvider: string; providerSchema: z.ZodType<string> };
10
+ executor?: { query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }> } | null;
11
+ createManagedStore?: (provider: InventoryBindingDependencies["provider"]) => {
12
+ readPort: InventoryReadPort; mutationPort: InventoryMutationPort;
13
+ } | null;
14
+ newPurchaseStockReadPort?: NewPurchaseStockReadPort | null;
15
+ now?: () => string;
16
+ }
17
+
18
+ export interface InventoryBindings {
19
+ readPort: InventoryReadPort | null;
20
+ mutationPort: InventoryMutationPort | null;
21
+ newPurchaseStockReadPort: NewPurchaseStockReadPort | null;
22
+ readiness: { ready: boolean; reason: string | null };
23
+ unsupported: { newPurchaseStockRead: string | null };
24
+ }
25
+
26
+ /** Select one store only. Factories declare bindings, never discover or activate a host. */
27
+ export function createInventoryBindings(deps: InventoryBindingDependencies): InventoryBindings {
28
+ const refused = (reason: string): InventoryBindings => ({
29
+ readPort: null, mutationPort: null, newPurchaseStockReadPort: null,
30
+ readiness: { ready: false, reason }, unsupported: { newPurchaseStockRead: reason },
31
+ });
32
+ if (!deps.provider?.providerSchema?.safeParse(deps.provider.stockMasterProvider).success) return refused("inventory_provider_mismatch");
33
+ let readPort: InventoryReadPort, mutationPort: InventoryMutationPort;
34
+ if (deps.dataKind === "postgres") {
35
+ if (!deps.executor || typeof deps.executor.query !== "function") return refused("inventory_executor_required");
36
+ readPort = mutationPort = createPostgresAdminInventoryPort(deps.executor, deps.provider, deps.now);
37
+ } else if (deps.dataKind === "supabase") {
38
+ const store = deps.createManagedStore?.(deps.provider);
39
+ if (!store) return refused("inventory_managed_store_required");
40
+ ({ readPort, mutationPort } = store);
41
+ } else return refused("inventory_store_incompatible");
42
+ if (![readPort?.listStock, readPort?.listReservations, readPort?.checkAtp, readPort?.forecastSubscriptions, mutationPort?.adjustStock].every(fn => typeof fn === "function")) return refused("inventory_store_incompatible");
43
+ const responseSchema = createInventorySubscriptionForecastResponseSchema(deps.provider.providerSchema);
44
+ const native = deps.dataKind === "postgres";
45
+ const stockRead = native ? null : deps.newPurchaseStockReadPort;
46
+ const newPurchaseStockReadPort = typeof stockRead?.read === "function" ? stockRead : null;
47
+ return {
48
+ readPort: {
49
+ listStock: request => readPort.listStock(request),
50
+ listReservations: request => readPort.listReservations(request),
51
+ checkAtp: request => readPort.checkAtp(request),
52
+ forecastSubscriptions: async request => responseSchema.parse(await readPort.forecastSubscriptions(request)),
53
+ },
54
+ mutationPort, newPurchaseStockReadPort,
55
+ readiness: { ready: true, reason: null },
56
+ unsupported: {
57
+ newPurchaseStockRead: native ? "inventory_native_new_purchase_stock_read_unsupported"
58
+ : newPurchaseStockReadPort ? null : "inventory_new_purchase_stock_reader_required",
59
+ },
60
+ };
61
+ }
@@ -19,3 +19,4 @@ export { createPostgresRenewalReservationPort, type AutomaticRenewalInventoryPor
19
19
  export { forecastSubscriptionInventoryShortages, buildSubscriptionInventoryForecast } from "./forecast.js";
20
20
  export { calculateSubscriptionForecastSkus, calculateSubscriptionForecastTotals } from "./subscriptionForecastCalculation.js";
21
21
  export { INVENTORY_MIGRATIONS } from "./sql.js";
22
+ export { createInventoryBindings, type InventoryBindingDependencies, type InventoryBindings } from "./bindings.js";
@@ -84,6 +84,71 @@ export function createCommercePriceAuthorityPort(
84
84
  };
85
85
  }
86
86
 
87
+ /** Opt-in scheduling using the selected list for concurrent history reads. */
88
+ export function createConcurrentCommercePriceAuthorityPort(
89
+ reads: CommercePriceAuthorityReadPort,
90
+ ): CommercePriceAuthorityPort {
91
+ return {
92
+ async resolvePrice(query): Promise<CommercePriceAuthorityResult> {
93
+ const atTime = query.atTime ?? new Date().toISOString();
94
+ const priceListId = selectUniqueEffectivePriceList(await reads.listPriceLists(query), atTime);
95
+
96
+ // Schedule history alongside base reads, retaining base-refusal precedence.
97
+ const policyHistoryRead = query.mode === "subscription" ? Promise.resolve().then(() =>
98
+ reads.listSubscriptionPolicyHistory({ priceListId, regionCode: query.regionCode,
99
+ currency: query.currency, channel: query.channel })).then(
100
+ (value) => ({ value }), (error: unknown) => ({ error }),
101
+ ) : null;
102
+
103
+ const baseCandidates = effective(await reads.listExactBaseEntries({
104
+ variantId: query.variantId,
105
+ priceListId,
106
+ }), atTime);
107
+ if (baseCandidates.length === 0) throw new CommercePriceAuthorityError("base_price_not_found");
108
+ if (baseCandidates.length !== 1) throw new CommercePriceAuthorityError("base_price_ambiguous");
109
+ if (baseCandidates[0]!.value.amountKind !== "gross") {
110
+ throw new CommercePriceAuthorityError("base_price_amount_kind_invalid");
111
+ }
112
+
113
+ const base = { ...baseCandidates[0]!.value, resolvedAt: atTime };
114
+ if (query.mode === "one_time") return { kind: "one_time", base, unitPriceMinor: base.unitPriceMinor };
115
+
116
+ const policyRead = await policyHistoryRead!;
117
+ if ("error" in policyRead) throw policyRead.error;
118
+ const policyHistory = policyRead.value;
119
+ if (policyHistory.length === 0) {
120
+ const legacyCandidates = effective(await reads.listExactLegacySubscriptionEntries({
121
+ variantId: query.variantId,
122
+ priceListId: base.priceListId,
123
+ }), atTime);
124
+ if (legacyCandidates.length === 0) throw new CommercePriceAuthorityError("legacy_subscription_price_not_found");
125
+ if (legacyCandidates.length !== 1) throw new CommercePriceAuthorityError("legacy_subscription_price_ambiguous");
126
+ const legacyResolved = { ...legacyCandidates[0]!.value, resolvedAt: atTime };
127
+ return { kind: "subscription_legacy", base, unitPriceMinor: legacyResolved.unitPriceMinor, legacyResolved };
128
+ }
129
+
130
+ const effectivePolicies = effective(policyHistory, atTime);
131
+ if (effectivePolicies.length === 0) throw new CommercePriceAuthorityError("subscription_price_policy_not_effective");
132
+ if (effectivePolicies.length !== 1) throw new CommercePriceAuthorityError("subscription_price_policy_ambiguous");
133
+ try {
134
+ const policy = await assertSubscriptionPricePolicyDigest(effectivePolicies[0]!.value);
135
+ return {
136
+ kind: "subscription_policy",
137
+ base,
138
+ unitPriceMinor: deriveSubscriptionUnitPrice(base.unitPriceMinor, policy),
139
+ policy,
140
+ };
141
+ } catch (error) {
142
+ throw new CommercePriceAuthorityError(
143
+ error instanceof SubscriptionPricePolicyDigestError
144
+ ? "subscription_price_policy_digest_mismatch"
145
+ : "subscription_price_policy_invalid",
146
+ );
147
+ }
148
+ },
149
+ };
150
+ }
151
+
87
152
  /** The current money context requires one list; creation order cannot resolve overlap. */
88
153
  export function selectUniqueEffectivePriceList(
89
154
  priceLists: readonly TimedAuthorityValue<string>[], atTime: string,
@@ -1,3 +1,4 @@
1
+ import type { ShippingTerms } from "../shipping/index.js";
1
2
  import type { ResolvedAcquisitionTerms } from "./contracts/acquisitionTerms.js";
2
3
  import { mapAcquisitionTermsFailure } from "./acquisitionQuoteBinding.js";
3
4
  import type { HttpRequest, HttpResponse } from "../http/index.js";
@@ -28,6 +29,7 @@ function commerceQuoteFailureDetails(stage: CommerceQuoteFailureStage) {
28
29
 
29
30
  export interface CommerceQuoteHandlerDeps<Request extends CreateQuoteRequest = CreateQuoteRequest, Response extends CreateQuoteResponse = CreateQuoteResponse> {
30
31
  responder: BffResponder;
32
+ shipping?: { terms(quote: Response["quote"]): ShippingTerms | undefined };
31
33
  acquisition?: { terms(quote: Response["quote"]): ResolvedAcquisitionTerms | undefined };
32
34
  createQuoteRequestSchema: z.ZodType<Request>;
33
35
  createQuoteResponseSchema: z.ZodType<Response>;
@@ -46,12 +48,14 @@ export interface CommerceQuoteHandlerDeps<Request extends CreateQuoteRequest = C
46
48
  request: Request,
47
49
  quote: Awaited<ReturnType<CommerceQuotePort<Request, Response>["createQuote"]>>["quote"],
48
50
  acquisitionTerms?: ResolvedAcquisitionTerms,
51
+ shippingTerms?: ShippingTerms,
49
52
  ) => string | null;
50
53
  honorPromotionAcceptance?: (
51
54
  token: string,
52
55
  request: Request,
53
56
  quote: Awaited<ReturnType<CommerceQuotePort<Request, Response>["createQuote"]>>["quote"],
54
57
  acquisitionTerms?: ResolvedAcquisitionTerms,
58
+ shippingTerms?: ShippingTerms,
55
59
  ) => boolean;
56
60
  }
57
61
 
@@ -59,6 +63,7 @@ export function createCommerceQuoteHandler<Request extends CreateQuoteRequest, R
59
63
  responder, createQuoteRequestSchema, createQuoteResponseSchema, publicCreateQuoteRequestSchema, publicCreateQuoteResponseSchema,
60
64
  quotePort,
61
65
  acquisition,
66
+ shipping,
62
67
  fallbackQuotePort,
63
68
  resolveCustomerEligibility,
64
69
  resolvePricingPolicy,
@@ -110,12 +115,22 @@ export function createCommerceQuoteHandler<Request extends CreateQuoteRequest, R
110
115
  let quote = canonicalQuote.data;
111
116
  stage = "promotion_acceptance";
112
117
  const acquisitionTerms = acquisition?.terms(quote.quote);
113
- const honoredToken = promotionAcceptanceToken && honorPromotionAcceptance?.(
114
- promotionAcceptanceToken, request.data, quote.quote, acquisitionTerms,
115
- ) ? promotionAcceptanceToken : undefined;
116
- const issuedToken = honoredToken ?? createPromotionAcceptance?.(request.data, quote.quote, acquisitionTerms) ?? undefined;
118
+ const shippingTerms = shipping?.terms(quote.quote);
119
+ const honoredToken = promotionAcceptanceToken && (shipping
120
+ ? honorPromotionAcceptance?.(promotionAcceptanceToken, request.data, quote.quote, acquisitionTerms, shippingTerms)
121
+ : honorPromotionAcceptance?.(promotionAcceptanceToken, request.data, quote.quote, acquisitionTerms))
122
+ ? promotionAcceptanceToken : undefined;
123
+ const issuedToken = honoredToken ?? (shipping
124
+ ? createPromotionAcceptance?.(request.data, quote.quote, acquisitionTerms, shippingTerms)
125
+ : createPromotionAcceptance?.(request.data, quote.quote, acquisitionTerms)) ?? undefined;
117
126
  const containsV2Promotion = quote.quote.discounts.some((discount) =>
118
127
  discount.promotionEngineVersion === "promotion-engine.v2");
128
+ if (shippingTerms && !issuedToken) {
129
+ sendBffError(res, "CONFLICT", "Shipping quote must be refreshed", {
130
+ details: { feature: "quote", reason: "promotion_acceptance_refresh_required" },
131
+ });
132
+ return;
133
+ }
119
134
  if ((containsV2Promotion || acquisitionTerms) && !issuedToken) {
120
135
  stage = "promotion_fallback";
121
136
  const fallback = fallbackQuotePort
@@ -124,7 +139,7 @@ export function createCommerceQuoteHandler<Request extends CreateQuoteRequest, R
124
139
  ...(pricingPolicy ? { pricingPolicy } : {}),
125
140
  }))
126
141
  : null;
127
- if (!fallback?.success || acquisition?.terms(fallback.data.quote) || fallback.data.quote.discounts.some((discount) =>
142
+ if (!fallback?.success || shipping?.terms(fallback.data.quote) || acquisition?.terms(fallback.data.quote) || fallback.data.quote.discounts.some((discount) =>
128
143
  discount.promotionEngineVersion === "promotion-engine.v2")) {
129
144
  sendBffError(res, "CONFLICT", "Promotion quote must be refreshed", {
130
145
  details: { feature: "quote", reason: "promotion_acceptance_refresh_required" },
@@ -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 { buildLineBreakdown } from "@openlup/core/pricing";
3
6
  import type { CreateQuoteResponse, PricingPolicySnapshot, CreateQuoteRequest, CommerceQuoteContext, CommerceQuoteLine, CommerceQuotePricingComponent, CommerceTaxProfile, CommerceQuoteDiscount, CommerceCodeRejection, CommerceCodeRejectionDetail, CartLineInput } from "./contracts/index.js";
@@ -27,6 +30,7 @@ export interface DbBackedCommerceQuotePortDeps<Request extends CreateQuoteReques
27
30
  currency: string;
28
31
  now?: () => string;
29
32
  shippingFlatMinor?: () => Promise<number>;
33
+ resolveQuoteShippingPromotions?: (input: QuoteShippingPromotionInput<Request>) => Promise<QuoteShippingPromotionResult>;
30
34
  hasPromotionCodePort: boolean;
31
35
  lineContext(line: CartLineInput, item: Item): LineContext;
32
36
  quoteContext(request: Request, lines: Array<LineContext & { line: CommerceQuoteLine }>): () => Context;
@@ -52,14 +56,14 @@ export function createDbBackedCommerceQuotePort<
52
56
  now = () => new Date().toISOString(),
53
57
  quoteCatalogReadPort, isStrictD1Quote, pricingResolverPort,
54
58
  commercePriceAuthorityPort, shippingFlatMinor, resolveAutomaticQuotePromotions,
55
- applyPromotionCodesV2, hasPromotionCodePort, lineContext, quoteContext,
59
+ applyPromotionCodesV2, hasPromotionCodePort, lineContext, quoteContext, resolveQuoteShippingPromotions,
56
60
  } = deps;
57
61
  assertSupportedCommerceTaxProfile(taxProfile, supportedTaxProfiles);
58
62
  const money = moneyIn(currency);
59
63
  async function createServerAuthoritativeQuote(
60
64
  request: Request,
61
65
  options?: CreateQuoteOptions,
62
- ): Promise<Omit<CreateQuoteResponse, "quote"> & { quote: Omit<CreateQuoteResponse["quote"], "context"> & { context: Context & { pricingPolicy?: PricingPolicySnapshot } } }> {
66
+ ): Promise<Omit<CreateQuoteResponse, "quote"> & { quote: Omit<CreateQuoteResponse["quote"], "context"> & { context: Context & { pricingPolicy?: PricingPolicySnapshot; shippingTerms?: ShippingTerms; acquisitionTerms?: ResolvedAcquisitionTerms } } }> {
63
67
  const atTime = now();
64
68
  const skuIndex = await buildSkuIndex(quoteCatalogReadPort);
65
69
  const pricedLines = await Promise.all(request.lines.map(async (requestLine) => {
@@ -178,33 +182,48 @@ export function createDbBackedCommerceQuotePort<
178
182
  const referenceProductMinor = sum(pricedLines.map((entry) => entry.referenceGrossMinor));
179
183
  const netTotalMinor = sum(lines.map((line) => line.tax.netAmount.amountMinor));
180
184
  const taxTotalMinor = sum(lines.map((line) => line.tax.vatAmount.amountMinor));
181
- // Shipping is a flat per-order gross from settings; absent settings port ⇒ no
182
- // shipping (byte-identical to pre-shipping quotes). Resolved before promo eval so
183
- // the free-shipping lane has a real amount to zero.
184
- const shippingGrossMinor = shippingFlatMinor
185
- ? await shippingFlatMinor()
186
- : 0;
187
- const automaticPromotionResult = await resolveAutomaticQuotePromotions({
188
- request,
189
- options,
190
- regionCode,
191
- referenceProductMinor,
192
- subtotalGrossMinor,
193
- shippingGrossMinor,
194
- atTime,
195
- });
196
- const promotionResult = await applyPromotionCodesV2({
197
- preserveLegacyDiscountsWhenNoCandidates: options?.pricingPolicy?.promotionEngineVersion !== PROMOTION_ENGINE_V2,
198
- clientId: options?.clientId ?? null,
199
- mode: request.mode,
200
- promoCodes: request.promoCodes,
201
- referenceProductMinor: sum(pricedLines.map((entry) => entry.referenceGrossMinor)),
202
- subtotalGrossMinor,
203
- shippingGrossMinor,
204
- discounts: automaticPromotionResult.discounts,
205
- codeRejections: automaticPromotionResult.codeRejections,
206
- atTime,
207
- });
185
+ let shippingGrossMinor: number;
186
+ let promotionResult: QuotePromotionResult;
187
+ let hasShipping: boolean;
188
+ let shippingTerms: ShippingTerms | undefined;
189
+ let acquisitionTerms: ResolvedAcquisitionTerms | undefined;
190
+ if (resolveQuoteShippingPromotions) {
191
+ const resolved = await resolveQuoteShippingPromotions({ request, options, regionCode, currency, referenceProductMinor, subtotalGrossMinor, atTime });
192
+ shippingGrossMinor = resolved.shippingGrossMinor;
193
+ promotionResult = resolved;
194
+ hasShipping = resolved.hasShipping;
195
+ shippingTerms = resolved.shippingTerms;
196
+ acquisitionTerms = resolved.acquisitionTerms;
197
+ } else {
198
+ // Shipping is a flat per-order gross from settings; absent settings port ⇒ no
199
+ // shipping (byte-identical to pre-shipping quotes). Resolved before promo eval so
200
+ // the free-shipping lane has a real amount to zero.
201
+ shippingGrossMinor = shippingFlatMinor
202
+ ? await shippingFlatMinor()
203
+ : 0;
204
+ const automaticPromotionResult = await resolveAutomaticQuotePromotions({
205
+ request,
206
+ options,
207
+ regionCode,
208
+ referenceProductMinor,
209
+ subtotalGrossMinor,
210
+ shippingGrossMinor,
211
+ atTime,
212
+ });
213
+ promotionResult = await applyPromotionCodesV2({
214
+ preserveLegacyDiscountsWhenNoCandidates: options?.pricingPolicy?.promotionEngineVersion !== PROMOTION_ENGINE_V2,
215
+ clientId: options?.clientId ?? null,
216
+ mode: request.mode,
217
+ promoCodes: request.promoCodes,
218
+ referenceProductMinor: sum(pricedLines.map((entry) => entry.referenceGrossMinor)),
219
+ subtotalGrossMinor,
220
+ shippingGrossMinor,
221
+ discounts: automaticPromotionResult.discounts,
222
+ codeRejections: automaticPromotionResult.codeRejections,
223
+ atTime,
224
+ });
225
+ hasShipping = shippingFlatMinor != null;
226
+ }
208
227
  const { discounts, codeRejections, codeRejectionDetails } = promotionResult;
209
228
  // Two lanes: order_total/line discounts reduce the subtotal; a shipping discount
210
229
  // (free-shipping promo) reduces the shipping gross. They are summed and validated
@@ -222,7 +241,6 @@ export function createDbBackedCommerceQuotePort<
222
241
  );
223
242
  const ship = splitIncludedVat(shippingGrossMinor, taxProfile.vatRateBps);
224
243
  const shipDiscount = splitIncludedVat(shippingDiscountMinor, taxProfile.vatRateBps);
225
- const hasShipping = shippingFlatMinor != null;
226
244
  const shippingFields = hasShipping
227
245
  ? {
228
246
  shippingGross: money(shippingGrossMinor),
@@ -241,6 +259,8 @@ export function createDbBackedCommerceQuotePort<
241
259
  pricingComponents: quoteComponents,
242
260
  context: {
243
261
  ...context(),
262
+ ...(resolveQuoteShippingPromotions && acquisitionTerms ? { acquisitionTerms } : {}),
263
+ ...(resolveQuoteShippingPromotions && shippingTerms ? { shippingTerms } : {}),
244
264
  ...(options?.pricingPolicy ? { pricingPolicy: options.pricingPolicy } : {}),
245
265
  },
246
266
  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";
@@ -50,3 +50,4 @@ export * from "./acquisitionOfferPricingSchemas.js";
50
50
  export * from "./acquisitionOfferPricing.js";
51
51
  export * from "./acquisitionOfferPricingHandler.js";
52
52
  export * from "./acquisitionReadiness.js";
53
+ export type { QuoteShippingPromotionInput, QuoteShippingPromotionResult } from "./quoteShippingPromotions.js";
@@ -0,0 +1,21 @@
1
+ import type { ShippingTerms } from "../shipping/index.js";
2
+ import type { ResolvedAcquisitionTerms } from "./contracts/acquisitionTerms.js";
3
+ import type { CreateQuoteRequest } from "./contracts/quote.js";
4
+ import type { CreateQuoteOptions } from "./quotePorts.js";
5
+ import type { QuotePromotionResult } from "./dbBackedCommerceQuotePort.js";
6
+
7
+ export interface QuoteShippingPromotionInput<Request extends CreateQuoteRequest> {
8
+ request: Request;
9
+ options?: CreateQuoteOptions;
10
+ regionCode: string;
11
+ currency: string;
12
+ referenceProductMinor: number;
13
+ subtotalGrossMinor: number;
14
+ atTime: string;
15
+ }
16
+ export interface QuoteShippingPromotionResult extends QuotePromotionResult {
17
+ shippingGrossMinor: number;
18
+ hasShipping: boolean;
19
+ shippingTerms?: ShippingTerms;
20
+ acquisitionTerms?: ResolvedAcquisitionTerms;
21
+ }