@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
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,53 @@ SemVer promise.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.19.0]
|
|
12
|
+
|
|
13
|
+
- Publishable on the npm `latest` dist-tag as `0.19.0`, from tag
|
|
14
|
+
`openlup-platform-v0.19.0`.
|
|
15
|
+
|
|
16
|
+
### 0.18.0 → 0.19.0 set scope
|
|
17
|
+
|
|
18
|
+
- Prepare the complete development-preview minor set at `0.19.0`, including everything on main since `0.18.0`; core and outbox are unchanged republications, and UI stays private `0.0.0`.
|
|
19
|
+
- M05 parity adds opt-in shipping-promise consent, post-discount threshold shipping and acquisition orchestration to the experimental pricing, promotions and shipping entries; financial policy and acquisition capability remain host supplied.
|
|
20
|
+
- M05 parity adds effective native bundle selection and an authoritative preview hook, optional resolved-target/detail/feed context fields and `selectEffectiveBundleTargetPrice`; effective feeds require unambiguous gross unit anchors and whole-BOM readiness.
|
|
21
|
+
- Add `createConcurrentCommercePriceAuthorityPort` with concurrent history scheduling, selected-list identity and base-refusal precedence; browser additions are type-only.
|
|
22
|
+
- Catalog/contracts draft canonical JSON detects lone surrogates without regex lookbehind so it loads in Safari before 16.4, with unchanged behavior.
|
|
23
|
+
- M06 P5a adds explicit inventory store/provider bindings and structural inventory contribution, keeping reference catalog-stock composition inert and managed authorization intact; native new-purchase stock reads remain unsupported pending an owner decision and native adjustment still refuses.
|
|
24
|
+
- M06 P5a retains focused installed-consumer, runtime-role, managed FEFO/adjustment and consume/refund replay evidence with unproved parity gates retained in the M06 ledger.
|
|
25
|
+
- M06 P5b adds opt-in shipping selection, address and flat-rate bindings with injected validation and authority; native address writes remain unsupported and refuse required readiness.
|
|
26
|
+
- M06 P5b adds the final isolated installed inventory/shipping consumer, preserving incumbent selection errors and flat-rate behavior.
|
|
27
|
+
- M06 in progress: inventory and shipping entries are early surfaces; module not complete
|
|
28
|
+
- Supporting main changes sort platform registration lists one item per line and use the database clock in the M05 P7 quote witness; neither adds runtime behavior.
|
|
29
|
+
|
|
30
|
+
Migration: upgrade the complete `@openlup/*` set from `0.18.0` to `0.19.0` for exact internal pins; existing bindings need no change and new module adoption remains explicit and experimental. Hosts selecting shipping promises must forward `shippingTerms` through both acceptance callbacks and use a context schema that preserves the selected terms. Bind `resolveQuoteShippingPromotions` to the promotions factory to select the bounded two-pass threshold path; financial policy and acquisition capability remain host supplied. The additive APIs require a minor set and introduce no activation. For both M05 24b and M06 P5b, the pricing and promotions API reports reorder existing enum/union members because declaration emit order changed (24b follows the catalog-to-pricing import); no member is added, removed or changed and callers need no migration for that reorder. Install the complete canonical migration catalogue, never sparse package projections as an installation chain; this preparation adds no SQL migration.
|
|
31
|
+
|
|
32
|
+
### Known limitations
|
|
33
|
+
|
|
34
|
+
- D8-1: native advisory v2 promo-code candidates and rejection details are not supported on the native backend.
|
|
35
|
+
- D8-2: native mounted v2 code issue/honor/fallback is not supported on the native backend.
|
|
36
|
+
- D8-3: acquisition-only mounted issuance/honor is not supported on the native backend.
|
|
37
|
+
- D8-4: full native/managed eligibility equivalence is not supported on the native backend.
|
|
38
|
+
- Bundle-1: independent database human-only actor-kind enforcement is not supported on the native backend and is the first item of a future native-backend parity round.
|
|
39
|
+
- Bundle-2: managed-equivalent actor-kind/email history is not supported on the native backend.
|
|
40
|
+
- Bundle-3 remains closed for M05 mounted admin/feed readback with an injected stock reader, while native new-purchase stock reads remain unsupported despite P5a's installed native inventory-store read proof.
|
|
41
|
+
- Bundle-4 establishes only incumbent real-PostgreSQL contention characterization and provides no exactly-once guarantee.
|
|
42
|
+
- The managed baseline schema and its pgTAP tests retain the incumbent legacy value `44.404`; removing it needs a forward migration and an owner decision, while reference composition supplies that value to application code.
|
|
43
|
+
- A1 evidence is offline only: account verification, hosted-time behaviour and owner TEST certification are not claimed, and the `communications/klaviyo` adapter's capabilities stay verification-pending.
|
|
44
|
+
- M05 parity seams are opt-in and omitted bindings preserve incumbent defaults, outputs, read order, scheduling, refusals, acceptance-token bytes and exported SQL.
|
|
45
|
+
- Native inventory adjustment remains unsupported; native address writes and flat-rate saves refuse required-capability readiness.
|
|
46
|
+
- M06 managed host ATP/forecast/supply/pagination, remaining compensation/concurrency/lock-order, exact-prefix upgrade/ledger, address idempotency/conflict/readback and runtime rate authority remain pending in the M06 ledger.
|
|
47
|
+
- M06 adopter stock-wait lifecycle, durable subscription timing and staging control observations retain their separate C5/C6 evidence gates.
|
|
48
|
+
|
|
49
|
+
### Size note
|
|
50
|
+
|
|
51
|
+
Only platform `pack.docs.maxPackedBytes` rises from 40,960 to 49,152 B; all other caps and budgets remain unchanged. All three baselines use integrity-verified published `0.18.0` tarballs.
|
|
52
|
+
|
|
53
|
+
Question (2026-10-21): "The platform package's root docs (README + CHANGELOG) will exceed their 40,960 B gzip cap at the next release. What should we do?"
|
|
54
|
+
Owner's answer: "Raise to 49,152 B (Recommended)"
|
|
55
|
+
Option description shown to the owner: "Same figure as core. Unblocks the next few releases; the CHANGELOG grows every release, so this cap would otherwise block each one."
|
|
56
|
+
Recorded as an owner decision taken through the coordinator's question on 2026-10-21.
|
|
57
|
+
|
|
11
58
|
## [0.18.0]
|
|
12
59
|
|
|
13
60
|
- Publishable on the npm `latest` dist-tag as `0.18.0`, from tag
|
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@ Development preview. This package joins core and outbox in the publishable set f
|
|
|
4
4
|
|
|
5
5
|
It holds the runnable server substrate an OpenLup application composes: the Node HTTP runtime, BFF dispatch, scheduling and job leases, blob storage, data gateways, the node-postgres migration runner and bundle composition. Ports and their contracts stay in `@openlup/core/platform-runtime`.
|
|
6
6
|
|
|
7
|
-
For the prepared `0.
|
|
8
|
-
limitations](CHANGELOG.md#
|
|
9
|
-
|
|
10
|
-
The size gate compares this candidate with the published `0.
|
|
7
|
+
For the prepared `0.19.0` set, read the [adjacent-release migration and known
|
|
8
|
+
limitations](CHANGELOG.md#0190) before selecting the opt-in M05 parity seams or
|
|
9
|
+
M06 composition bindings. M06 in progress: inventory and shipping entries are early surfaces; module not complete
|
|
10
|
+
The size gate compares this candidate with the published `0.18.0` tarball;
|
|
11
11
|
preparation, offline adapter evidence and a passing size cap grant no publication
|
|
12
12
|
or provider-account certification.
|
|
13
13
|
|
|
@@ -47,6 +47,8 @@ No vendor code, inventory import, activation or parity claim.
|
|
|
47
47
|
|
|
48
48
|
## Inventory
|
|
49
49
|
|
|
50
|
+
`createInventoryBindings` selects one store with exact host provider validation. Native new-purchase reads refuse as `inventory_native_new_purchase_stock_read_unsupported`; `createInventoryContribution` declares selected callables without activation.
|
|
51
|
+
|
|
50
52
|
`INVENTORY_MIGRATIONS` projects canonical units 27/33 byte-for-byte; install the full catalogue. Store support and pending proof: [M06 parity](../../docs/platform/plans/oss-m06-stock-reservations-delivery.md#parity-pending-rows).
|
|
51
53
|
|
|
52
54
|
## Promotions
|
|
@@ -65,8 +67,8 @@ require the floor. No factory supplies a default financial policy.
|
|
|
65
67
|
the host's exact request-binding projection; `promoCodes` also binds separately.
|
|
66
68
|
Its four issue/verify functions retain public-money projection, section diagnostics,
|
|
67
69
|
TTL and rotation. Explicit `acquisitionTerms` binds the full server-selected promise.
|
|
68
|
-
The pricing quote handler passes
|
|
69
|
-
acceptance callbacks. A host must forward them to the acceptance helper. With no
|
|
70
|
+
The pricing quote handler passes resolved acquisition terms to both
|
|
71
|
+
acceptance callbacks. An opt-in shipping binding appends shipping terms. A host must forward them to the acceptance helper. With no
|
|
70
72
|
acquisition configuration, terms are absent and historical token bytes stay equal.
|
|
71
73
|
This opt-in signature addition does not activate acquisition-only mounted issuance.
|
|
72
74
|
|
|
@@ -113,6 +115,10 @@ full native/managed eligibility matrix are accepted native-backend capability
|
|
|
113
115
|
gaps, without a parity claim, in the
|
|
114
116
|
[module ledger](../../docs/platform/plans/oss-m05-catalog-offers-pricing.md#d8-pending-outcome-ledger).
|
|
115
117
|
|
|
118
|
+
Opt-in `shippingTerms` also binds shipping-only promises. `createQuoteShippingPromotions`
|
|
119
|
+
resolves post-discount thresholds; optional `acquisition` bindings select host policy.
|
|
120
|
+
Omission preserves incumbent behavior. See [parity seams](../../docs/platform/CANONICAL_CONTRACTS.md#m05-parity-seams).
|
|
121
|
+
|
|
116
122
|
## Pricing
|
|
117
123
|
|
|
118
124
|
`./pricing` provides the existing OpenLup strict commerce price authority,
|
|
@@ -122,7 +128,9 @@ price-list, base-entry, legacy-entry and policy-history reads. The authority
|
|
|
122
128
|
validates the base before reading history and uses the observed base's
|
|
123
129
|
`priceListId` for that lookup. History excludes legacy fallback even when no
|
|
124
130
|
revision is currently effective. Digest, interval, ambiguity and amount-kind
|
|
125
|
-
refusals retain their existing identities and precedence.
|
|
131
|
+
refusals retain their existing identities and precedence. The opt-in
|
|
132
|
+
`createConcurrentCommercePriceAuthorityPort` instead schedules history alongside
|
|
133
|
+
base reads using the selected list ID, with base-refusal precedence.
|
|
126
134
|
|
|
127
135
|
`./pricing/contracts` contains dependency-free authority observations, policy
|
|
128
136
|
DTOs and version constants; its only external type comes from the exact
|
|
@@ -186,7 +194,9 @@ terms can join the existing acceptance money projection and totals section;
|
|
|
186
194
|
absence contributes no property. Quote/batch refusal mapping and unsigned-terms
|
|
187
195
|
fallback checks require explicit acquisition configuration. Reference composition
|
|
188
196
|
supplies none: existing schemas, headers, reads, error details and token bytes
|
|
189
|
-
are preserved.
|
|
197
|
+
are preserved. Threshold shipping is supplied by the separate
|
|
198
|
+
[parity seam](../../docs/platform/CANONICAL_CONTRACTS.md#m05-parity-seams).
|
|
199
|
+
Checkout admission, claims, finalization,
|
|
190
200
|
promotion implementation, stores, SQL, routes and activation remain outside
|
|
191
201
|
this API.
|
|
192
202
|
|
|
@@ -198,6 +208,10 @@ in the application. The existing cold consumer exercises both entries and
|
|
|
198
208
|
compiles installed declarations without optional provider peers.
|
|
199
209
|
|
|
200
210
|
## Catalog
|
|
211
|
+
Opt-in `priceAuthority: "effective"` on the native bundle store and the admin
|
|
212
|
+
`effectiveTarget` hook provide [effective bundle parity](../../docs/platform/CANONICAL_CONTRACTS.md#m05-parity-seams);
|
|
213
|
+
`selectEffectiveBundleTargetPrice` is exported. Omitted seams preserve defaults.
|
|
214
|
+
|
|
201
215
|
The native envelope and dark draft adapters now live in this entry as
|
|
202
216
|
`createPostgresCatalogSkuEnvelopeReadPort` and `createPostgresCatalogDraftStore`.
|
|
203
217
|
They accept an injected executor and retain the existing mapping, errors and SQL
|
|
@@ -210,7 +224,8 @@ The composition entry adds `createCatalogContribution` and its structural input
|
|
|
210
224
|
interface. It describes actual catalog, pricing and promotions callables under
|
|
211
225
|
those existing module identities, with no environment discovery, client creation
|
|
212
226
|
or registration. It imports only readiness types: composition gains no catalog
|
|
213
|
-
edge, and catalog
|
|
227
|
+
edge, and catalog keeps its type-only data edge plus one pricing value edge for
|
|
228
|
+
the effective-window selector. Package boundary
|
|
214
229
|
checks pin those imports and peer-free composition declarations. The packed
|
|
215
230
|
consumer exercises both new stores and the descriptor without optional peers;
|
|
216
231
|
its synthetic executor proves named refusal, not durable storage behavior.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { BundleAdminBindings, AuthorizeAdmin } from "./adminBindings.js";
|
|
2
2
|
import type { BundleAvailabilityService } from "./bundleAvailabilityService.js";
|
|
3
|
-
import type { BundleCatalogReadPort } from "./bundleCatalogReadPort.js";
|
|
3
|
+
import type { BundleCatalogReadPort, BundleDetail, BundleTargetPriceRow } from "./bundleCatalogReadPort.js";
|
|
4
4
|
export interface AdminBundleReadHandlerDeps {
|
|
5
|
+
effectiveTarget?: (bundle: BundleDetail) => BundleTargetPriceRow | null;
|
|
5
6
|
readPort: BundleCatalogReadPort;
|
|
6
7
|
authorizeAdmin: AuthorizeAdmin;
|
|
7
8
|
readsEnabled: boolean;
|
|
@@ -107,7 +107,7 @@ export function createAdminBundleReadHandlers({ bundleSpec, BundleRpcError, COMM
|
|
|
107
107
|
});
|
|
108
108
|
if (!bundle)
|
|
109
109
|
throw new BundleRpcError("P0002", "bundle_not_found");
|
|
110
|
-
return previewBundlePrice(bundle, input);
|
|
110
|
+
return previewBundlePrice(bundle, input, deps.effectiveTarget);
|
|
111
111
|
}, (preview) => ({ contractVersion: COMMERCE_CONTRACT_VERSION, preview }), previewBundlePriceResponseSchema, "Invalid bundle price preview response");
|
|
112
112
|
}
|
|
113
113
|
/** The detail the transport carries: the port's internal variant id stays server-side. */
|
|
@@ -140,7 +140,7 @@ export function createAdminBundleReadHandlers({ bundleSpec, BundleRpcError, COMM
|
|
|
140
140
|
* no priced list (P0002), an unpriced component or an empty set (P0001), and the
|
|
141
141
|
* kernel's own three failure codes (P0001).
|
|
142
142
|
*/
|
|
143
|
-
function previewBundlePrice(bundle, input) {
|
|
143
|
+
function previewBundlePrice(bundle, input, effectiveTarget) {
|
|
144
144
|
if (!bundle.resolvedPriceListId || !bundle.resolvedCurrency) {
|
|
145
145
|
throw new BundleRpcError("P0002", "no_active_price_list");
|
|
146
146
|
}
|
|
@@ -151,7 +151,7 @@ export function createAdminBundleReadHandlers({ bundleSpec, BundleRpcError, COMM
|
|
|
151
151
|
if (unpriced.length > 0) {
|
|
152
152
|
throw new BundleRpcError("P0001", `component_unpriced:${unpriced[0].sku}`);
|
|
153
153
|
}
|
|
154
|
-
const activePrice = bundle.prices.find((price) => price.active && (!input.mode || price.mode === input.mode));
|
|
154
|
+
const activePrice = effectiveTarget ? effectiveTarget(bundle) : bundle.prices.find((price) => price.active && (!input.mode || price.mode === input.mode));
|
|
155
155
|
const targetPriceMinor = input.targetPriceMinor ?? activePrice?.targetPriceMinor;
|
|
156
156
|
if (targetPriceMinor === undefined)
|
|
157
157
|
throw new BundleRpcError("P0002", "no_active_target_price");
|
|
@@ -170,7 +170,7 @@ export function createAdminBundleReadHandlers({ bundleSpec, BundleRpcError, COMM
|
|
|
170
170
|
return {
|
|
171
171
|
bundleCode: bundle.code,
|
|
172
172
|
priceListId: bundle.resolvedPriceListId,
|
|
173
|
-
mode: input.mode ?? activePrice?.mode ?? "any",
|
|
173
|
+
mode: input.mode ?? activePrice?.mode ?? (effectiveTarget ? "one_time" : "any"),
|
|
174
174
|
currency: bundle.resolvedCurrency,
|
|
175
175
|
referenceTotalMinor: allocation.referenceTotalMinor,
|
|
176
176
|
targetPriceMinor: allocation.targetPriceMinor,
|
|
@@ -91,6 +91,8 @@ export interface BundleDetail extends BundleSummary {
|
|
|
91
91
|
metadata: Record<string, unknown>;
|
|
92
92
|
components: BundleComponentRow[];
|
|
93
93
|
prices: BundleTargetPriceRow[];
|
|
94
|
+
/** Present only when the host selects effective price authority. */
|
|
95
|
+
resolvedTargetPrice?: BundleTargetPriceRow | null;
|
|
94
96
|
/** The currency whose list resolved `referenceUnitPriceMinor`; `null` if none did. */
|
|
95
97
|
resolvedCurrency: string | null;
|
|
96
98
|
/**
|
|
@@ -108,8 +110,10 @@ export interface GetBundleInput {
|
|
|
108
110
|
* only currency an operator has actually chosen for it.
|
|
109
111
|
*/
|
|
110
112
|
currency?: string;
|
|
111
|
-
/** Narrow the target price whose list is selected; omitted means any mode. */
|
|
113
|
+
/** Narrow the target price whose list is selected; omitted means any mode (the opt-in effective store defaults to one_time). */
|
|
112
114
|
mode?: string;
|
|
115
|
+
regionCode?: string;
|
|
116
|
+
atTime?: string;
|
|
113
117
|
}
|
|
114
118
|
/**
|
|
115
119
|
* One live, sellable bundle: active status, an active target price, and its
|
|
@@ -156,6 +160,9 @@ export interface BundleCatalogReadPort {
|
|
|
156
160
|
*/
|
|
157
161
|
listActiveBundleCompositions(input?: {
|
|
158
162
|
currency?: string;
|
|
163
|
+
regionCode?: string;
|
|
164
|
+
mode?: "one_time" | "subscription";
|
|
165
|
+
atTime?: string;
|
|
159
166
|
}): Promise<ActiveBundleComposition[]>;
|
|
160
167
|
/**
|
|
161
168
|
* The operator trail for one bundle. The two chains answer from different
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { BundleCatalogReadPort } from "./bundleCatalogReadPort.js";
|
|
2
2
|
import type { PgQueryExecutor } from "../data/index.js";
|
|
3
|
-
export declare function createPostgresBundleCatalogStore(executor: PgQueryExecutor
|
|
3
|
+
export declare function createPostgresBundleCatalogStore(executor: PgQueryExecutor, options?: {
|
|
4
|
+
priceAuthority?: "effective";
|
|
5
|
+
}): BundleCatalogReadPort;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { createEffectiveBundleCatalogStore } from "./effectiveBundleCatalogStore.js";
|
|
1
2
|
import { BundleCatalogReadError, } from "./bundleCatalogReadPort.js";
|
|
2
3
|
import { LIST_SQL, COUNT_SQL, DETAIL_SQL, COMPONENT_SQL, FEED_COMPONENT_SQL, PRICE_SQL, OWN_LIST_SQL, CURRENCY_LIST_SQL, FEED_SQL, HISTORY_SQL, HISTORY_COUNT_SQL, } from "./bundleCatalogQueries.js";
|
|
3
|
-
export function createPostgresBundleCatalogStore(executor) {
|
|
4
|
+
export function createPostgresBundleCatalogStore(executor, options) {
|
|
5
|
+
if (options?.priceAuthority === "effective") {
|
|
6
|
+
return createEffectiveBundleCatalogStore(executor, createPostgresBundleCatalogStore(executor));
|
|
7
|
+
}
|
|
4
8
|
async function rows(sql, values) {
|
|
5
9
|
// A driver failure becomes the port's own read error, so a caller sees the
|
|
6
10
|
// same distinction between "found nothing" and "could not ask" on both chains.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type BundleTargetPriceRow } from "./bundleCatalogReadPort.js";
|
|
2
|
+
export declare function uniqueBundlePrice<T extends {
|
|
3
|
+
mode: string;
|
|
4
|
+
}>(rows: readonly T[], mode: string): T | null;
|
|
5
|
+
export declare function selectEffectiveBundleTargetPrice(rows: readonly {
|
|
6
|
+
price: BundleTargetPriceRow;
|
|
7
|
+
priceListId: string;
|
|
8
|
+
}[], context: {
|
|
9
|
+
priceListId: string | null;
|
|
10
|
+
currency: string | null;
|
|
11
|
+
mode: string;
|
|
12
|
+
atTime: string;
|
|
13
|
+
}): BundleTargetPriceRow | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { selectUniqueEffectivePriceList } from "../pricing/index.js";
|
|
2
|
+
import { BundleCatalogReadError } from "./bundleCatalogReadPort.js";
|
|
3
|
+
/** Half-open windows share the price authority's effective-time rule. */
|
|
4
|
+
function bundlePriceEffective(row, atTime) {
|
|
5
|
+
try {
|
|
6
|
+
selectUniqueEffectivePriceList([{ value: "row", ...row }], atTime);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function uniqueBundlePrice(rows, mode) {
|
|
14
|
+
const exact = rows.filter(row => row.mode === mode);
|
|
15
|
+
const candidates = exact.length ? exact : rows.filter(row => row.mode === "any");
|
|
16
|
+
if (candidates.length > 1)
|
|
17
|
+
throw new BundleCatalogReadError("bundle_price_ambiguous");
|
|
18
|
+
return candidates[0] ?? null;
|
|
19
|
+
}
|
|
20
|
+
export function selectEffectiveBundleTargetPrice(rows, context) {
|
|
21
|
+
if (context.priceListId === null || context.currency === null)
|
|
22
|
+
return null;
|
|
23
|
+
return uniqueBundlePrice(rows.filter(row => row.priceListId === context.priceListId
|
|
24
|
+
&& row.price.currency === context.currency && row.price.active && row.price.amountKind === "gross"
|
|
25
|
+
&& row.price.targetPriceMinor > 0 && bundlePriceEffective(row.price, context.atTime)).map(row => row.price), context.mode);
|
|
26
|
+
}
|
|
@@ -65,6 +65,15 @@ export type GetBundleResponse = {
|
|
|
65
65
|
validFrom: string;
|
|
66
66
|
validTo: string | null;
|
|
67
67
|
}[];
|
|
68
|
+
resolvedTargetPrice?: {
|
|
69
|
+
mode: string;
|
|
70
|
+
targetPriceMinor: number;
|
|
71
|
+
currency: string;
|
|
72
|
+
amountKind: string;
|
|
73
|
+
active: boolean;
|
|
74
|
+
validFrom: string;
|
|
75
|
+
validTo: string | null;
|
|
76
|
+
} | null;
|
|
68
77
|
resolvedCurrency: string | null;
|
|
69
78
|
availability: {
|
|
70
79
|
sellableNow: number | null;
|
|
@@ -113,6 +122,15 @@ export type BundleDetailView = {
|
|
|
113
122
|
validFrom: string;
|
|
114
123
|
validTo: string | null;
|
|
115
124
|
}[];
|
|
125
|
+
resolvedTargetPrice?: {
|
|
126
|
+
mode: string;
|
|
127
|
+
targetPriceMinor: number;
|
|
128
|
+
currency: string;
|
|
129
|
+
amountKind: string;
|
|
130
|
+
active: boolean;
|
|
131
|
+
validFrom: string;
|
|
132
|
+
validTo: string | null;
|
|
133
|
+
} | null;
|
|
116
134
|
resolvedCurrency: string | null;
|
|
117
135
|
availability: {
|
|
118
136
|
sellableNow: number | null;
|
|
@@ -7,7 +7,7 @@ export function canonicalCatalogDraftJson(input) {
|
|
|
7
7
|
if (depth > 48)
|
|
8
8
|
throw new Error("draft_json_depth");
|
|
9
9
|
if (typeof value === "string") {
|
|
10
|
-
if (value.includes("\u0000") || /[\uD800-\
|
|
10
|
+
if (value.includes("\u0000") || /[\uD800-\uDFFF]/u.test(value))
|
|
11
11
|
throw new Error("draft_json_invalid");
|
|
12
12
|
return value;
|
|
13
13
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const COMPONENT_SQL: string;
|
|
2
|
+
export declare const FEED_COMPONENT_SQL: string;
|
|
3
|
+
export declare const PRICE_SQL = "\n SELECT p.mode, p.price_list_id, p.target_price_minor, list.currency, p.amount_kind, p.active, p.valid_from, p.valid_to\n FROM public.catalog_bundle_prices p JOIN public.price_lists list ON list.id = p.price_list_id\n WHERE p.bundle_id = $1::uuid ORDER BY p.valid_from DESC";
|
|
4
|
+
export declare const OWN_LIST_SQL = "\n SELECT list.id, list.currency, list.region_code, p.mode\n FROM public.catalog_bundle_prices p JOIN public.price_lists list ON list.id = p.price_list_id\n WHERE p.bundle_id = $1::uuid AND p.active AND p.amount_kind = 'gross'\n AND (p.mode = $2::text OR p.mode = 'any') AND list.status = 'active'\n AND p.valid_from <= $3::timestamptz AND (p.valid_to IS NULL OR p.valid_to > $3::timestamptz)\n AND list.valid_from <= $3::timestamptz AND (list.valid_to IS NULL OR list.valid_to > $3::timestamptz)\n ORDER BY p.valid_from DESC";
|
|
5
|
+
export declare const CURRENCY_LIST_SQL = "\n SELECT id, currency, region_code FROM public.price_lists\n WHERE currency = $1::text AND status = 'active' AND ($2::text IS NULL OR region_code = $2::text)\n AND valid_from <= $3::timestamptz AND (valid_to IS NULL OR valid_to > $3::timestamptz)";
|
|
6
|
+
export declare const FEED_SQL = "\n SELECT b.id, b.code, b.title, b.fulfillment_mode, p.mode, p.target_price_minor, p.price_list_id, list.currency, list.region_code\n FROM public.catalog_bundles b\n JOIN public.catalog_bundle_prices p ON p.bundle_id = b.id AND p.active\n JOIN public.price_lists list ON list.id = p.price_list_id\n WHERE b.status = 'active' AND p.amount_kind = 'gross' AND p.target_price_minor > 0\n AND list.status = 'active' AND ($1::text IS NULL OR list.currency = $1::text)\n AND ($2::text IS NULL OR list.region_code = $2::text) AND (p.mode = $4::text OR p.mode = 'any')\n AND p.valid_from <= $3::timestamptz AND (p.valid_to IS NULL OR p.valid_to > $3::timestamptz)\n AND list.valid_from <= $3::timestamptz AND (list.valid_to IS NULL OR list.valid_to > $3::timestamptz)\n ORDER BY b.code, p.valid_from DESC";
|
|
7
|
+
export declare const DETAIL_SQL = "SELECT b.id, b.code, b.title, b.status, b.fulfillment_mode, b.updated_at,\n b.composition_constraint, b.metadata,\n (SELECT count(*) FROM public.catalog_bundle_components c WHERE c.bundle_id = b.id) AS component_count,\n EXISTS (SELECT 1 FROM public.catalog_bundle_prices p JOIN public.price_lists list ON list.id = p.price_list_id\n WHERE p.bundle_id = b.id AND p.active AND p.amount_kind = 'gross' AND p.target_price_minor > 0\n AND p.mode IN ('one_time', 'any') AND list.status = 'active'\n AND p.valid_from <= CURRENT_TIMESTAMP AND (p.valid_to IS NULL OR p.valid_to > CURRENT_TIMESTAMP)\n AND list.valid_from <= CURRENT_TIMESTAMP AND (list.valid_to IS NULL OR list.valid_to > CURRENT_TIMESTAMP)) AS has_active_target_price FROM public.catalog_bundles b WHERE b.code = $1";
|
|
8
|
+
export declare const LIST_SQL = "SELECT b.id, b.code, b.title, b.status, b.fulfillment_mode, b.updated_at,\n b.composition_constraint, b.metadata,\n (SELECT count(*) FROM public.catalog_bundle_components c WHERE c.bundle_id = b.id) AS component_count,\n EXISTS (SELECT 1 FROM public.catalog_bundle_prices p JOIN public.price_lists list ON list.id = p.price_list_id\n WHERE p.bundle_id = b.id AND p.active AND p.amount_kind = 'gross' AND p.target_price_minor > 0\n AND p.mode IN ('one_time', 'any') AND list.status = 'active'\n AND p.valid_from <= CURRENT_TIMESTAMP AND (p.valid_to IS NULL OR p.valid_to > CURRENT_TIMESTAMP)\n AND list.valid_from <= CURRENT_TIMESTAMP AND (list.valid_to IS NULL OR list.valid_to > CURRENT_TIMESTAMP)) AS has_active_target_price FROM public.catalog_bundles b\n WHERE ($1::text = 'all' OR b.status = $1::text)\n AND ($2::text IS NULL OR b.code ILIKE '%' || $2 || '%' OR b.title ILIKE '%' || $2 || '%')\n ORDER BY b.code LIMIT $3 OFFSET $4";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Effective-authority SQL is isolated from the incumbent exported statements. */
|
|
2
|
+
const ANCHORS = `SELECT pe.unit_price_minor FROM public.price_entries pe
|
|
3
|
+
WHERE pe.variant_id = s.id AND pe.price_list_id = %LIST% AND pe.active
|
|
4
|
+
AND pe.min_qty = 1 AND pe.amount_kind = 'gross'
|
|
5
|
+
AND pe.valid_from <= $3::timestamptz AND (pe.valid_to IS NULL OR pe.valid_to > $3::timestamptz)
|
|
6
|
+
AND (pe.mode = $4::text OR (pe.mode = 'any' AND NOT EXISTS (
|
|
7
|
+
SELECT 1 FROM public.price_entries exact WHERE exact.variant_id = s.id
|
|
8
|
+
AND exact.price_list_id = pe.price_list_id AND exact.active AND exact.min_qty = 1
|
|
9
|
+
AND exact.amount_kind = 'gross' AND exact.mode = $4::text
|
|
10
|
+
AND exact.valid_from <= $3::timestamptz AND (exact.valid_to IS NULL OR exact.valid_to > $3::timestamptz))))`;
|
|
11
|
+
const FACTS = `s.status AS sku_status, s.sellable_standalone, s.sellable_in_subscription,
|
|
12
|
+
s.net_weight_g, pr.status AS product_status,
|
|
13
|
+
(pr.primary_sku_id IS NOT NULL AND revision.id IS NOT NULL) AS document_ready,
|
|
14
|
+
COALESCE((SELECT jsonb_agg(jsonb_build_object('issuer', identifier.issuer, 'normalizedValue', identifier.normalized_value,
|
|
15
|
+
'kind', identifier.identifier_kind, 'packKind', identifier.pack_kind, 'packQuantity', identifier.quantity, 'isPrimary', identifier.is_primary))
|
|
16
|
+
FROM public.catalog_sku_identifiers identifier WHERE identifier.catalog_sku_id = s.id), '[]'::jsonb) AS identifiers`;
|
|
17
|
+
const REFERENCE = `(SELECT (array_agg(candidate.unit_price_minor))[1] FROM (${ANCHORS}) candidate)`;
|
|
18
|
+
const COUNT = `(SELECT count(*) FROM (${ANCHORS}) candidate)`;
|
|
19
|
+
export const COMPONENT_SQL = `
|
|
20
|
+
SELECT c.bundle_id, s.sku, s.title, pr.slug AS product_slug, s.id AS variant_id,
|
|
21
|
+
c.quantity, c.is_addon, c.sort_order, ${FACTS},
|
|
22
|
+
${REFERENCE.replace("%LIST%", "$2::uuid")} AS reference_unit_price_minor,
|
|
23
|
+
${COUNT.replace("%LIST%", "$2::uuid")} AS reference_price_count
|
|
24
|
+
FROM public.catalog_bundle_components c
|
|
25
|
+
JOIN public.catalog_skus s ON s.id = c.catalog_sku_id
|
|
26
|
+
JOIN public.catalog_products pr ON pr.id = s.product_id
|
|
27
|
+
LEFT JOIN public.catalog_product_document_revisions revision ON revision.id = pr.current_document_revision_id AND revision.product_id = pr.id
|
|
28
|
+
WHERE c.bundle_id = $1::uuid ORDER BY c.sort_order, s.sku`;
|
|
29
|
+
export const FEED_COMPONENT_SQL = `
|
|
30
|
+
SELECT c.bundle_id, s.sku, s.title, pr.slug AS product_slug, s.id AS variant_id,
|
|
31
|
+
c.quantity, c.is_addon, c.sort_order, ${FACTS},
|
|
32
|
+
${REFERENCE.replace("%LIST%", "selected.price_list_id")} AS reference_unit_price_minor,
|
|
33
|
+
${COUNT.replace("%LIST%", "selected.price_list_id")} AS reference_price_count
|
|
34
|
+
FROM unnest($1::uuid[], $2::uuid[]) AS selected(bundle_id, price_list_id)
|
|
35
|
+
JOIN public.catalog_bundle_components c ON c.bundle_id = selected.bundle_id
|
|
36
|
+
JOIN public.catalog_skus s ON s.id = c.catalog_sku_id
|
|
37
|
+
JOIN public.catalog_products pr ON pr.id = s.product_id
|
|
38
|
+
LEFT JOIN public.catalog_product_document_revisions revision ON revision.id = pr.current_document_revision_id AND revision.product_id = pr.id
|
|
39
|
+
ORDER BY c.bundle_id, c.sort_order, s.sku`;
|
|
40
|
+
export const PRICE_SQL = `
|
|
41
|
+
SELECT p.mode, p.price_list_id, p.target_price_minor, list.currency, p.amount_kind, p.active, p.valid_from, p.valid_to
|
|
42
|
+
FROM public.catalog_bundle_prices p JOIN public.price_lists list ON list.id = p.price_list_id
|
|
43
|
+
WHERE p.bundle_id = $1::uuid ORDER BY p.valid_from DESC`;
|
|
44
|
+
export const OWN_LIST_SQL = `
|
|
45
|
+
SELECT list.id, list.currency, list.region_code, p.mode
|
|
46
|
+
FROM public.catalog_bundle_prices p JOIN public.price_lists list ON list.id = p.price_list_id
|
|
47
|
+
WHERE p.bundle_id = $1::uuid AND p.active AND p.amount_kind = 'gross'
|
|
48
|
+
AND (p.mode = $2::text OR p.mode = 'any') AND list.status = 'active'
|
|
49
|
+
AND p.valid_from <= $3::timestamptz AND (p.valid_to IS NULL OR p.valid_to > $3::timestamptz)
|
|
50
|
+
AND list.valid_from <= $3::timestamptz AND (list.valid_to IS NULL OR list.valid_to > $3::timestamptz)
|
|
51
|
+
ORDER BY p.valid_from DESC`;
|
|
52
|
+
export const CURRENCY_LIST_SQL = `
|
|
53
|
+
SELECT id, currency, region_code FROM public.price_lists
|
|
54
|
+
WHERE currency = $1::text AND status = 'active' AND ($2::text IS NULL OR region_code = $2::text)
|
|
55
|
+
AND valid_from <= $3::timestamptz AND (valid_to IS NULL OR valid_to > $3::timestamptz)`;
|
|
56
|
+
export const FEED_SQL = `
|
|
57
|
+
SELECT b.id, b.code, b.title, b.fulfillment_mode, p.mode, p.target_price_minor, p.price_list_id, list.currency, list.region_code
|
|
58
|
+
FROM public.catalog_bundles b
|
|
59
|
+
JOIN public.catalog_bundle_prices p ON p.bundle_id = b.id AND p.active
|
|
60
|
+
JOIN public.price_lists list ON list.id = p.price_list_id
|
|
61
|
+
WHERE b.status = 'active' AND p.amount_kind = 'gross' AND p.target_price_minor > 0
|
|
62
|
+
AND list.status = 'active' AND ($1::text IS NULL OR list.currency = $1::text)
|
|
63
|
+
AND ($2::text IS NULL OR list.region_code = $2::text) AND (p.mode = $4::text OR p.mode = 'any')
|
|
64
|
+
AND p.valid_from <= $3::timestamptz AND (p.valid_to IS NULL OR p.valid_to > $3::timestamptz)
|
|
65
|
+
AND list.valid_from <= $3::timestamptz AND (list.valid_to IS NULL OR list.valid_to > $3::timestamptz)
|
|
66
|
+
ORDER BY b.code, p.valid_from DESC`;
|
|
67
|
+
const SUMMARY_COLUMNS = `b.id, b.code, b.title, b.status, b.fulfillment_mode, b.updated_at,
|
|
68
|
+
b.composition_constraint, b.metadata,
|
|
69
|
+
(SELECT count(*) FROM public.catalog_bundle_components c WHERE c.bundle_id = b.id) AS component_count,
|
|
70
|
+
EXISTS (SELECT 1 FROM public.catalog_bundle_prices p JOIN public.price_lists list ON list.id = p.price_list_id
|
|
71
|
+
WHERE p.bundle_id = b.id AND p.active AND p.amount_kind = 'gross' AND p.target_price_minor > 0
|
|
72
|
+
AND p.mode IN ('one_time', 'any') AND list.status = 'active'
|
|
73
|
+
AND p.valid_from <= CURRENT_TIMESTAMP AND (p.valid_to IS NULL OR p.valid_to > CURRENT_TIMESTAMP)
|
|
74
|
+
AND list.valid_from <= CURRENT_TIMESTAMP AND (list.valid_to IS NULL OR list.valid_to > CURRENT_TIMESTAMP)) AS has_active_target_price`;
|
|
75
|
+
export const DETAIL_SQL = `SELECT ${SUMMARY_COLUMNS} FROM public.catalog_bundles b WHERE b.code = $1`;
|
|
76
|
+
export const LIST_SQL = `SELECT ${SUMMARY_COLUMNS} FROM public.catalog_bundles b
|
|
77
|
+
WHERE ($1::text = 'all' OR b.status = $1::text)
|
|
78
|
+
AND ($2::text IS NULL OR b.code ILIKE '%' || $2 || '%' OR b.title ILIKE '%' || $2 || '%')
|
|
79
|
+
ORDER BY b.code LIMIT $3 OFFSET $4`;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PgQueryExecutor } from "../data/index.js";
|
|
2
|
+
import { type BundleCatalogReadPort } from "./bundleCatalogReadPort.js";
|
|
3
|
+
/** Selected only by the explicit effective-authority option. */
|
|
4
|
+
export declare function createEffectiveBundleCatalogStore(executor: PgQueryExecutor, incumbent: BundleCatalogReadPort): BundleCatalogReadPort;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { assertPrimaryUnitGtinAuthority } from "./contracts/foundation.js";
|
|
2
|
+
import { BundleCatalogReadError } from "./bundleCatalogReadPort.js";
|
|
3
|
+
import { uniqueBundlePrice, selectEffectiveBundleTargetPrice } from "./bundlePriceSelection.js";
|
|
4
|
+
import { COUNT_SQL } from "./bundleCatalogQueries.js";
|
|
5
|
+
import { LIST_SQL, DETAIL_SQL, COMPONENT_SQL, FEED_COMPONENT_SQL, PRICE_SQL, OWN_LIST_SQL, CURRENCY_LIST_SQL, FEED_SQL } from "./effectiveBundleCatalogQueries.js";
|
|
6
|
+
/** Selected only by the explicit effective-authority option. */
|
|
7
|
+
export function createEffectiveBundleCatalogStore(executor, incumbent) {
|
|
8
|
+
async function rows(sql, values) {
|
|
9
|
+
const result = await executor.query(sql, values).catch((error) => {
|
|
10
|
+
throw new BundleCatalogReadError(error instanceof Error ? error.message : String(error));
|
|
11
|
+
});
|
|
12
|
+
return (result.rows ?? []);
|
|
13
|
+
}
|
|
14
|
+
async function selectPriceList(bundleId, input) {
|
|
15
|
+
const atTime = input.atTime ?? new Date().toISOString(), mode = input.mode ?? "one_time";
|
|
16
|
+
const own = input.currency ? [] : await rows(OWN_LIST_SQL, [bundleId, mode, atTime]);
|
|
17
|
+
const chosenPrice = uniqueBundlePrice(own, mode), chosen = chosenPrice ? [chosenPrice] : [];
|
|
18
|
+
const currency = input.currency ?? chosen[0]?.currency;
|
|
19
|
+
if (!currency)
|
|
20
|
+
return null;
|
|
21
|
+
const candidates = await rows(CURRENCY_LIST_SQL, [currency, input.regionCode ?? chosen[0]?.region_code ?? null, atTime]);
|
|
22
|
+
if (candidates.length > 1)
|
|
23
|
+
throw new BundleCatalogReadError("bundle_price_list_ambiguous");
|
|
24
|
+
if (chosen[0] && candidates[0] && chosen[0].id !== candidates[0].id)
|
|
25
|
+
throw new BundleCatalogReadError("bundle_price_list_changed");
|
|
26
|
+
return candidates[0] ?? null;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...incumbent,
|
|
30
|
+
async listBundles(input) {
|
|
31
|
+
const filter = [input.status, input.query ?? null];
|
|
32
|
+
const [page, counted] = await Promise.all([
|
|
33
|
+
rows(LIST_SQL, [...filter, input.limit, input.offset]),
|
|
34
|
+
rows(COUNT_SQL, filter),
|
|
35
|
+
]);
|
|
36
|
+
return { bundles: page.map(toSummary), total: Number(counted[0]?.total ?? page.length) };
|
|
37
|
+
},
|
|
38
|
+
async getBundle(input) {
|
|
39
|
+
const bundle = (await rows(DETAIL_SQL, [input.code]))[0];
|
|
40
|
+
if (!bundle)
|
|
41
|
+
return null;
|
|
42
|
+
const atTime = input.atTime ?? new Date().toISOString();
|
|
43
|
+
const list = await selectPriceList(bundle.id, { ...input, atTime });
|
|
44
|
+
const [componentRows, priceRows] = await Promise.all([
|
|
45
|
+
rows(COMPONENT_SQL, [bundle.id, list?.id ?? null, atTime, input.mode ?? "one_time"]).then(rows => rows.map(toComponent)),
|
|
46
|
+
rows(PRICE_SQL, [bundle.id]),
|
|
47
|
+
]);
|
|
48
|
+
return {
|
|
49
|
+
...toSummary(bundle), compositionConstraint: toConstraint(bundle.composition_constraint), metadata: bundle.metadata ?? {},
|
|
50
|
+
components: componentRows, prices: priceRows.map(toPrice),
|
|
51
|
+
resolvedTargetPrice: selectEffectiveBundleTargetPrice(priceRows.map(row => ({ price: toPrice(row), priceListId: row.price_list_id })), { priceListId: list?.id ?? null, currency: list?.currency ?? null, mode: input.mode ?? "one_time", atTime }),
|
|
52
|
+
resolvedCurrency: list?.currency ?? null, resolvedPriceListId: list?.id ?? null,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
async listActiveBundleCompositions(input = {}) {
|
|
56
|
+
const atTime = input.atTime ?? new Date().toISOString(), mode = input.mode ?? "one_time";
|
|
57
|
+
const feed = await rows(FEED_SQL, [input.currency ?? null, input.regionCode ?? null, atTime, mode]);
|
|
58
|
+
const selected = new Map();
|
|
59
|
+
const listReads = new Map();
|
|
60
|
+
for (const row of feed) {
|
|
61
|
+
const same = feed.filter(candidate => candidate.id === row.id);
|
|
62
|
+
const candidate = uniqueBundlePrice(same, mode);
|
|
63
|
+
const key = `${row.currency}:${row.region_code}`;
|
|
64
|
+
if (!listReads.has(key))
|
|
65
|
+
listReads.set(key, rows(CURRENCY_LIST_SQL, [row.currency, row.region_code ?? null, atTime]));
|
|
66
|
+
const lists = await listReads.get(key);
|
|
67
|
+
if (lists.length > 1)
|
|
68
|
+
throw new BundleCatalogReadError("bundle_price_list_ambiguous");
|
|
69
|
+
if (lists[0]?.id !== row.price_list_id)
|
|
70
|
+
throw new BundleCatalogReadError("bundle_price_list_changed");
|
|
71
|
+
selected.set(row.id, candidate);
|
|
72
|
+
}
|
|
73
|
+
if (!selected.size)
|
|
74
|
+
return [];
|
|
75
|
+
const chosen = [...selected.values()];
|
|
76
|
+
const source = await rows(FEED_COMPONENT_SQL, [chosen.map(row => row.id), chosen.map(row => row.price_list_id), atTime, mode]);
|
|
77
|
+
const byBundle = groupComponents(source);
|
|
78
|
+
const sellable = chosen.filter(row => {
|
|
79
|
+
const components = source.filter(component => component.bundle_id === row.id);
|
|
80
|
+
return components.length > 0 && components.every(component => componentReady(component, mode));
|
|
81
|
+
});
|
|
82
|
+
return sellable.map(row => ({ code: row.code, title: row.title, fulfillmentMode: row.fulfillment_mode,
|
|
83
|
+
currency: row.currency, targetPriceMinor: row.target_price_minor, mode: row.mode, components: byBundle.get(row.id) ?? [] }));
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function groupComponents(rows) {
|
|
88
|
+
const grouped = new Map();
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
const bucket = grouped.get(row.bundle_id) ?? [];
|
|
91
|
+
bucket.push(toComponent(row));
|
|
92
|
+
grouped.set(row.bundle_id, bucket);
|
|
93
|
+
}
|
|
94
|
+
return grouped;
|
|
95
|
+
}
|
|
96
|
+
function toComponent(row) {
|
|
97
|
+
if (Number(row.reference_price_count) > 1)
|
|
98
|
+
throw new BundleCatalogReadError("bundle_price_ambiguous");
|
|
99
|
+
return {
|
|
100
|
+
sku: row.sku,
|
|
101
|
+
title: row.title ?? null,
|
|
102
|
+
productSlug: row.product_slug,
|
|
103
|
+
variantId: row.variant_id,
|
|
104
|
+
quantity: Number(row.quantity),
|
|
105
|
+
isAddon: row.is_addon === true,
|
|
106
|
+
sortOrder: Number(row.sort_order),
|
|
107
|
+
referenceUnitPriceMinor: row.reference_unit_price_minor === null || row.reference_unit_price_minor === undefined
|
|
108
|
+
? null
|
|
109
|
+
: Number(row.reference_unit_price_minor),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function toSummary(row) {
|
|
113
|
+
return {
|
|
114
|
+
code: row.code,
|
|
115
|
+
title: row.title,
|
|
116
|
+
status: row.status,
|
|
117
|
+
fulfillmentMode: row.fulfillment_mode,
|
|
118
|
+
componentCount: Number(row.component_count ?? 0),
|
|
119
|
+
hasActiveTargetPrice: row.has_active_target_price === true,
|
|
120
|
+
updatedAt: row.updated_at,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function toPrice(row) {
|
|
124
|
+
return {
|
|
125
|
+
mode: row.mode,
|
|
126
|
+
targetPriceMinor: Number(row.target_price_minor),
|
|
127
|
+
currency: row.currency,
|
|
128
|
+
amountKind: row.amount_kind,
|
|
129
|
+
active: row.active === true,
|
|
130
|
+
validFrom: row.valid_from,
|
|
131
|
+
validTo: row.valid_to ?? null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** An empty envelope is stored as `{}`; that is "unconstrained", not a constraint. */
|
|
135
|
+
function toConstraint(value) {
|
|
136
|
+
if (!value || typeof value.kind !== "string" || value.kind.trim() === "")
|
|
137
|
+
return null;
|
|
138
|
+
return {
|
|
139
|
+
kind: value.kind,
|
|
140
|
+
version: typeof value.version === "number" ? value.version : 0,
|
|
141
|
+
data: (value.data ?? {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function componentReady(row, mode) {
|
|
145
|
+
try {
|
|
146
|
+
assertPrimaryUnitGtinAuthority(row.identifiers);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return row.sku_status === "active" && row.product_status === "active" && row.document_ready === true
|
|
152
|
+
&& Number.isSafeInteger(row.net_weight_g) && row.net_weight_g > 0
|
|
153
|
+
&& (mode === "subscription" ? row.sellable_in_subscription : row.sellable_standalone)
|
|
154
|
+
&& row.reference_unit_price_minor !== null && Number.isSafeInteger(Number(row.reference_unit_price_minor))
|
|
155
|
+
&& Number(row.reference_unit_price_minor) > 0;
|
|
156
|
+
}
|
package/dist/catalog/index.d.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/dist/catalog/index.js
CHANGED
|
@@ -23,3 +23,4 @@ export { BUNDLE_MIGRATIONS, CATALOG_MIGRATIONS } from "./sql.js";
|
|
|
23
23
|
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";
|
|
24
24
|
export { createPostgresCatalogSkuEnvelopeReadPort } from "./catalogSkuEnvelope.js";
|
|
25
25
|
export { createPostgresCatalogDraftStore } from "./catalogDraftStore.js";
|
|
26
|
+
export { selectEffectiveBundleTargetPrice } from "./bundlePriceSelection.js";
|
|
@@ -2,3 +2,5 @@ export { createBundleComposer, type ComposeBundle, type PlatformBundle, type Pla
|
|
|
2
2
|
export { bindNodeHttpPort, createScheduledEntrypointResolver } from "./httpBinding.js";
|
|
3
3
|
export { createNodeHost, type NodeHost, type NodeHostAdmission, type NodeHostDependencies, type NodeSchedulerProfile, type ServeNodeOptions, type ServeNodeResult, } from "./nodeHost.js";
|
|
4
4
|
export { createCatalogContribution, type CatalogContributionInput } from "./catalogContribution.js";
|
|
5
|
+
export { createInventoryContribution } from "./inventoryContribution.js";
|
|
6
|
+
export { createShippingContribution } from "./shippingContribution.js";
|
|
@@ -2,3 +2,5 @@ export { createBundleComposer, } from "./composeBundle.js";
|
|
|
2
2
|
export { bindNodeHttpPort, createScheduledEntrypointResolver } from "./httpBinding.js";
|
|
3
3
|
export { createNodeHost, } from "./nodeHost.js";
|
|
4
4
|
export { createCatalogContribution } from "./catalogContribution.js";
|
|
5
|
+
export { createInventoryContribution } from "./inventoryContribution.js";
|
|
6
|
+
export { createShippingContribution } from "./shippingContribution.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
|
|
2
|
+
/** Structural selected callables keep inventory and catalog declarations independent. */
|
|
3
|
+
export declare function createInventoryContribution(input: {
|
|
4
|
+
version: string;
|
|
5
|
+
requiredSchema: RequiredSchema;
|
|
6
|
+
inventory: 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
|
+
};
|