@openlup/platform 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +24 -9
- package/dist/catalog/adminBundleReadHandler.d.ts +2 -1
- package/dist/catalog/adminBundleReadHandler.js +4 -4
- package/dist/catalog/bundleCatalogReadPort.d.ts +8 -1
- package/dist/catalog/bundleCatalogStore.d.ts +3 -1
- package/dist/catalog/bundleCatalogStore.js +5 -1
- package/dist/catalog/bundlePriceSelection.d.ts +13 -0
- package/dist/catalog/bundlePriceSelection.js +26 -0
- package/dist/catalog/contracts/bundleRead.d.ts +18 -0
- package/dist/catalog/contracts/draft.js +1 -1
- package/dist/catalog/effectiveBundleCatalogQueries.d.ts +8 -0
- package/dist/catalog/effectiveBundleCatalogQueries.js +79 -0
- package/dist/catalog/effectiveBundleCatalogStore.d.ts +4 -0
- package/dist/catalog/effectiveBundleCatalogStore.js +156 -0
- package/dist/catalog/index.d.ts +1 -0
- package/dist/catalog/index.js +1 -0
- package/dist/composition/index.d.ts +2 -0
- package/dist/composition/index.js +2 -0
- package/dist/composition/inventoryContribution.d.ts +16 -0
- package/dist/composition/inventoryContribution.js +17 -0
- package/dist/composition/shippingContribution.d.ts +16 -0
- package/dist/composition/shippingContribution.js +17 -0
- package/dist/inventory/bindings.d.ts +35 -0
- package/dist/inventory/bindings.js +45 -0
- package/dist/inventory/index.d.ts +1 -0
- package/dist/inventory/index.js +1 -0
- package/dist/pricing/commercePriceAuthority.d.ts +2 -0
- package/dist/pricing/commercePriceAuthority.js +61 -0
- package/dist/pricing/commerceQuoteHandler.d.ts +7 -3
- package/dist/pricing/commerceQuoteHandler.js +16 -4
- package/dist/pricing/dbBackedCommerceQuotePort.d.ts +8 -0
- package/dist/pricing/dbBackedCommerceQuotePort.js +46 -29
- package/dist/pricing/index.d.ts +2 -1
- package/dist/pricing/index.js +1 -1
- package/dist/pricing/offerPolicySchemas.d.ts +2 -2
- package/dist/pricing/offerPricingSchemas.d.ts +2 -2
- package/dist/pricing/pricingPolicyToken.d.ts +2 -2
- package/dist/pricing/quoteSchemas.d.ts +18 -18
- package/dist/pricing/quoteShippingPromotions.d.ts +20 -0
- package/dist/pricing/quoteShippingPromotions.js +1 -0
- package/dist/promotions/adminPromotionCodesContracts.d.ts +7 -7
- package/dist/promotions/commerceQuoteReadPreparation.d.ts +3 -0
- package/dist/promotions/commerceQuoteReadPreparation.js +27 -0
- package/dist/promotions/index.d.ts +3 -0
- package/dist/promotions/index.js +1 -0
- package/dist/promotions/promotionQuoteAcceptance.d.ts +5 -0
- package/dist/promotions/promotionQuoteAcceptance.js +12 -12
- package/dist/promotions/promotionQuoteBinding.d.ts +4 -2
- package/dist/promotions/promotionQuoteBinding.js +5 -3
- package/dist/promotions/quoteAcquisitionBinding.d.ts +6 -0
- package/dist/promotions/quoteAcquisitionBinding.js +1 -0
- package/dist/promotions/quoteAutomaticPromotions.d.ts +3 -1
- package/dist/promotions/quoteAutomaticPromotions.js +28 -4
- package/dist/promotions/quotePromoDiscounts.d.ts +5 -1
- package/dist/promotions/quotePromoDiscounts.js +8 -2
- package/dist/promotions/quotePromotionV2.d.ts +7 -6
- package/dist/promotions/quotePromotionV2.js +15 -3
- package/dist/promotions/quoteShippingPolicy.d.ts +23 -0
- package/dist/promotions/quoteShippingPolicy.js +47 -0
- package/dist/shipping/bindings.d.ts +57 -0
- package/dist/shipping/bindings.js +46 -0
- package/dist/shipping/contracts/index.d.ts +1 -0
- package/dist/shipping/contracts/threshold.d.ts +22 -0
- package/dist/shipping/contracts/threshold.js +1 -0
- package/dist/shipping/index.d.ts +3 -0
- package/dist/shipping/index.js +2 -0
- package/dist/shipping/thresholdPolicy.d.ts +42 -0
- package/dist/shipping/thresholdPolicy.js +60 -0
- package/package.json +2 -2
- package/release-gates.json +136 -134
- package/src/catalog/adminBundleReadHandler.ts +6 -4
- package/src/catalog/bundleCatalogReadPort.ts +6 -2
- package/src/catalog/bundleCatalogStore.ts +5 -0
- package/src/catalog/bundlePriceSelection.ts +25 -0
- package/src/catalog/contracts/bundleRead.ts +2 -2
- package/src/catalog/contracts/draft.ts +1 -1
- package/src/catalog/effectiveBundleCatalogQueries.ts +80 -0
- package/src/catalog/effectiveBundleCatalogStore.ts +208 -0
- package/src/catalog/index.ts +1 -0
- package/src/composition/index.ts +2 -0
- package/src/composition/inventoryContribution.ts +24 -0
- package/src/composition/shippingContribution.ts +24 -0
- package/src/inventory/bindings.ts +61 -0
- package/src/inventory/index.ts +1 -0
- package/src/pricing/commercePriceAuthority.ts +65 -0
- package/src/pricing/commerceQuoteHandler.ts +20 -5
- package/src/pricing/dbBackedCommerceQuotePort.ts +50 -30
- package/src/pricing/index.ts +2 -1
- package/src/pricing/quoteShippingPromotions.ts +21 -0
- package/src/promotions/commerceQuoteReadPreparation.ts +29 -0
- package/src/promotions/index.ts +3 -0
- package/src/promotions/promotionQuoteAcceptance.ts +17 -10
- package/src/promotions/promotionQuoteBinding.ts +7 -2
- package/src/promotions/quoteAcquisitionBinding.ts +7 -0
- package/src/promotions/quoteAutomaticPromotions.ts +30 -4
- package/src/promotions/quotePromoDiscounts.ts +11 -2
- package/src/promotions/quotePromotionV2.ts +20 -4
- package/src/promotions/quoteShippingPolicy.ts +61 -0
- package/src/shipping/bindings.ts +74 -0
- package/src/shipping/contracts/index.ts +1 -0
- package/src/shipping/contracts/threshold.ts +22 -0
- package/src/shipping/index.ts +3 -0
- package/src/shipping/thresholdPolicy.ts +65 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/catalog/index.ts
CHANGED
|
@@ -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";
|
package/src/composition/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/inventory/index.ts
CHANGED
|
@@ -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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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),
|
package/src/pricing/index.ts
CHANGED
|
@@ -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
|
+
}
|