@thorprovider/adapters 5.0.10 → 5.0.12
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/AGENTS.md +1 -1
- package/dist/index.d.mts +376 -16
- package/dist/index.d.ts +376 -16
- package/dist/index.js +1687 -528
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1680 -530
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/core/errors.ts +31 -0
- package/src/factory/index.ts +16 -6
- package/src/factory/provider-types.test.ts +32 -0
- package/src/factory/provider-types.ts +176 -0
- package/src/index.ts +6 -4
- package/src/providers/index.ts +1 -1
- package/src/providers/medusa/auth.test.ts +148 -25
- package/src/providers/medusa/auth.ts +129 -16
- package/src/providers/medusa/available-filters.test.ts +280 -0
- package/src/providers/medusa/catalog-facet-support.test.ts +715 -0
- package/src/providers/medusa/catalog-query.test.ts +422 -0
- package/src/providers/medusa/catalog-sort.test.ts +175 -0
- package/src/providers/medusa/customer-orders.test.ts +142 -0
- package/src/providers/medusa/index.test.ts +480 -0
- package/src/providers/medusa/index.ts +1592 -538
- package/src/providers/medusa/live-smoke.test.ts +68 -0
- package/src/providers/medusa/transforms.test.ts +222 -0
- package/src/providers/medusa/transforms.ts +152 -66
- package/src/providers/mock/catalog-query.test.ts +491 -0
- package/src/providers/mock/index.test.ts +66 -0
- package/src/providers/mock/index.ts +43 -1
- package/src/providers/mock/mock.ts +513 -3
- package/src/types/completion.ts +57 -0
- package/src/types/index.ts +7 -0
- package/.turbo/turbo-build.log +0 -18
- package/.turbo/turbo-test.log +0 -372
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/publish.log +0 -93
|
@@ -31,6 +31,8 @@ import type {
|
|
|
31
31
|
Customer,
|
|
32
32
|
GetCustomersOptions,
|
|
33
33
|
GetOrdersOptions,
|
|
34
|
+
GetCustomerOrdersPageOptions,
|
|
35
|
+
CustomerOrdersPage,
|
|
34
36
|
AdminProduct,
|
|
35
37
|
Region,
|
|
36
38
|
Address,
|
|
@@ -38,9 +40,20 @@ import type {
|
|
|
38
40
|
StorefrontConfig,
|
|
39
41
|
DesignerConfig,
|
|
40
42
|
DesignerHistoryEntry,
|
|
43
|
+
CatalogQuery,
|
|
44
|
+
CatalogAppliedFilters,
|
|
45
|
+
CatalogQueryOutcome,
|
|
46
|
+
CatalogFacetId,
|
|
47
|
+
CatalogFacetDemonstrationSource,
|
|
48
|
+
FacetSupport,
|
|
41
49
|
} from '@thorprovider/types';
|
|
42
|
-
import { StorefrontConfigError } from '
|
|
50
|
+
import { StorefrontConfigError } from '../../core/errors';
|
|
43
51
|
import type { CommerceProvider, ProviderConfig } from '../../types/provider';
|
|
52
|
+
import type {
|
|
53
|
+
CartCompletionCapable,
|
|
54
|
+
CartCompletionError,
|
|
55
|
+
CartCompletionResult,
|
|
56
|
+
} from '../../types/completion';
|
|
44
57
|
import { MEDUSA_CAPABILITIES } from '../../types/capabilities.types';
|
|
45
58
|
import { createLogger } from '../../core/logger';
|
|
46
59
|
import { fetchWithRetry, type RetryConfig } from '../../core/fetch-utils';
|
|
@@ -51,12 +64,378 @@ import { MedusaAuthProvider } from './auth';
|
|
|
51
64
|
import { getPaymentMethods } from './payment';
|
|
52
65
|
import {
|
|
53
66
|
buildChannelScopeMetadata,
|
|
54
|
-
filterByChannel,
|
|
55
67
|
validateChannelScopeAssignment,
|
|
56
68
|
} from '../../metadata';
|
|
57
69
|
|
|
58
70
|
const MEDUSA_CART_RETRIEVE_FIELDS =
|
|
59
|
-
'+items,+items.variant,+items.variant.product,+items.variant.product.thumbnail,+items.variant.options,+items.variant.options.option';
|
|
71
|
+
'+items,+items.variant,+items.variant.product,+items.variant.product.thumbnail,+items.variant.options,+items.variant.options.option,+items.total,+items.unit_price,+items.item_subtotal,+items.item_total,+shipping_methods,+shipping_methods.id,+shipping_methods.shipping_option_id';
|
|
72
|
+
|
|
73
|
+
/** Default window size for the channel-scoped order-history route. */
|
|
74
|
+
const CUSTOMER_ORDERS_DEFAULT_PAGE_SIZE = 10;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Upper bound on a single order-history window. The route is never asked for an
|
|
78
|
+
* unbounded/all-orders result (design D14); a caller requesting more is clamped.
|
|
79
|
+
*/
|
|
80
|
+
const CUSTOMER_ORDERS_MAX_PAGE_SIZE = 100;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize a 1-based page + pageSize into the backend's `limit`/`offset`.
|
|
84
|
+
* Non-integer or non-positive values fall back to the canonical defaults and the
|
|
85
|
+
* page size is clamped to {@link CUSTOMER_ORDERS_MAX_PAGE_SIZE}.
|
|
86
|
+
*/
|
|
87
|
+
function normalizeCustomerOrdersPage(options: GetCustomerOrdersPageOptions): {
|
|
88
|
+
page: number;
|
|
89
|
+
pageSize: number;
|
|
90
|
+
limit: number;
|
|
91
|
+
offset: number;
|
|
92
|
+
} {
|
|
93
|
+
const page =
|
|
94
|
+
Number.isInteger(options.page) && (options.page as number) > 0 ? (options.page as number) : 1;
|
|
95
|
+
const requested =
|
|
96
|
+
Number.isInteger(options.pageSize) && (options.pageSize as number) > 0
|
|
97
|
+
? (options.pageSize as number)
|
|
98
|
+
: CUSTOMER_ORDERS_DEFAULT_PAGE_SIZE;
|
|
99
|
+
const pageSize = Math.min(requested, CUSTOMER_ORDERS_MAX_PAGE_SIZE);
|
|
100
|
+
return { page, pageSize, limit: pageSize, offset: (page - 1) * pageSize };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fields requested when completing a cart into an order.
|
|
105
|
+
*
|
|
106
|
+
* The native `complete` default projection is minimal, so the returned order's
|
|
107
|
+
* `items` are empty. `*items` selects all fields of the `items` relation
|
|
108
|
+
* (Medusa field syntax: `*` retrieves all fields of a relation), which yields
|
|
109
|
+
* each line's `id`, `title`, `product_title`, `variant_title`, `quantity`,
|
|
110
|
+
* `unit_price` and `total` (all major units, backend-authoritative). The same
|
|
111
|
+
* query is applied when completion returns a cart-with-error, and `*items` is
|
|
112
|
+
* valid for both entities.
|
|
113
|
+
*/
|
|
114
|
+
const MEDUSA_ORDER_COMPLETION_FIELDS = '*items';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Per-request catalog listing bound (change B, decision 5).
|
|
118
|
+
*
|
|
119
|
+
* Listing is paginated with a configured per-request bound instead of an
|
|
120
|
+
* unbounded scan. Live probes against the acceptance backend accepted
|
|
121
|
+
* `limit` 1/50/100/200/1000, so 50 is a deliberately conservative *configured*
|
|
122
|
+
* bound — NOT a claimed backend maximum. Override with
|
|
123
|
+
* `MEDUSA_CATALOG_PAGE_SIZE` or `MedusaProviderConfig.catalogPageSize`.
|
|
124
|
+
*/
|
|
125
|
+
const DEFAULT_CATALOG_PAGE_SIZE = 50;
|
|
126
|
+
|
|
127
|
+
function resolveCatalogPageSize(configured?: number): number {
|
|
128
|
+
const fromEnv =
|
|
129
|
+
process.env.MEDUSA_CATALOG_PAGE_SIZE !== undefined
|
|
130
|
+
? Number(process.env.MEDUSA_CATALOG_PAGE_SIZE)
|
|
131
|
+
: undefined;
|
|
132
|
+
const raw = configured ?? fromEnv;
|
|
133
|
+
if (raw === undefined || !Number.isFinite(raw) || raw < 1) {
|
|
134
|
+
return DEFAULT_CATALOG_PAGE_SIZE;
|
|
135
|
+
}
|
|
136
|
+
return Math.floor(raw);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the configured sales channel for cart scoping.
|
|
141
|
+
*
|
|
142
|
+
* Prefers the server-only `MEDUSA_SALES_CHANNEL_ID`, then the legacy public
|
|
143
|
+
* `NEXT_PUBLIC_SALES_CHANNEL_ID`. Returns `undefined` when neither is set, so
|
|
144
|
+
* the caller can fall back to the backend's publishable-key channel.
|
|
145
|
+
*/
|
|
146
|
+
function resolveConfiguredSalesChannelId(): string | undefined {
|
|
147
|
+
return (
|
|
148
|
+
process.env.MEDUSA_SALES_CHANNEL_ID ??
|
|
149
|
+
process.env.NEXT_PUBLIC_SALES_CHANNEL_ID
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Canonical Thor sort keys → Medusa Store `/store/products` `order` field names.
|
|
155
|
+
*
|
|
156
|
+
* Medusa's Store API only accepts fields on the route's allowed list (for
|
|
157
|
+
* products: `title`, `created_at`, …) and fails a request with a 400 when the
|
|
158
|
+
* field is not allowed (Store API pagination/sort contract). Thor's canonical
|
|
159
|
+
* keys therefore cannot be passed through raw:
|
|
160
|
+
*
|
|
161
|
+
* - `featured` → no `order` (server default ordering)
|
|
162
|
+
* - `name` → `title`
|
|
163
|
+
* - `newest` → `-created_at`
|
|
164
|
+
*
|
|
165
|
+
* Legacy `SortOption` keys (`price-asc`, `price-desc`, `rating`) are not
|
|
166
|
+
* supported at the query level and map to `null` (server default), preserving
|
|
167
|
+
* the existing client-side behavior instead of sending an invalid order field.
|
|
168
|
+
* `null` means "omit `order` entirely".
|
|
169
|
+
*/
|
|
170
|
+
const MEDUSA_SORT_KEY_MAP: Record<string, string | null> = {
|
|
171
|
+
featured: null,
|
|
172
|
+
name: 'title',
|
|
173
|
+
newest: '-created_at',
|
|
174
|
+
'price-asc': null,
|
|
175
|
+
'price-desc': null,
|
|
176
|
+
rating: null,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Effective category scope for a canonical query (E2 task 2.2).
|
|
181
|
+
*
|
|
182
|
+
* The route category scope (`categoryScope`) is always retained; the
|
|
183
|
+
* `categories` filter narrows within it. Returns `undefined` when no category
|
|
184
|
+
* filter is requested, `[]` for an explicit empty intersection (a valid
|
|
185
|
+
* zero-result query), or the handles to resolve to backend IDs.
|
|
186
|
+
*/
|
|
187
|
+
function resolveEffectiveCategoryHandles(
|
|
188
|
+
query: CatalogQuery,
|
|
189
|
+
): string[] | undefined {
|
|
190
|
+
const { categoryScope, categories } = query;
|
|
191
|
+
if (categoryScope === undefined) return categories;
|
|
192
|
+
if (categories === undefined) return [categoryScope];
|
|
193
|
+
return categories.filter((handle) => handle === categoryScope);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Filters a successful (or explicitly-empty) canonical result reports as
|
|
198
|
+
* applied. Every requested filter that the adapter actually applied appears
|
|
199
|
+
* here; unappliable filters never reach this path (they return a typed error).
|
|
200
|
+
*
|
|
201
|
+
* `includeAdvanced` is set only on a path that genuinely applied the requested
|
|
202
|
+
* price/stock/attribute filters (the advanced endpoint execution, or a
|
|
203
|
+
* zero-result scope short-circuit where the requested filters are moot because
|
|
204
|
+
* the scope already matches nothing). The basic `/store/products` path never
|
|
205
|
+
* includes them — it does not accept those parameters.
|
|
206
|
+
*/
|
|
207
|
+
function buildAppliedCatalogFilters(
|
|
208
|
+
query: CatalogQuery,
|
|
209
|
+
effectiveCategoryHandles: string[] | undefined,
|
|
210
|
+
includeAdvanced = false,
|
|
211
|
+
): CatalogAppliedFilters {
|
|
212
|
+
return {
|
|
213
|
+
...(query.text ? { text: query.text } : {}),
|
|
214
|
+
...(effectiveCategoryHandles !== undefined
|
|
215
|
+
? { categories: effectiveCategoryHandles }
|
|
216
|
+
: {}),
|
|
217
|
+
...(query.collections !== undefined
|
|
218
|
+
? { collections: query.collections }
|
|
219
|
+
: {}),
|
|
220
|
+
...(includeAdvanced && query.priceMin !== undefined
|
|
221
|
+
? { priceMin: query.priceMin }
|
|
222
|
+
: {}),
|
|
223
|
+
...(includeAdvanced && query.priceMax !== undefined
|
|
224
|
+
? { priceMax: query.priceMax }
|
|
225
|
+
: {}),
|
|
226
|
+
...(includeAdvanced && query.inStock !== undefined
|
|
227
|
+
? { inStock: query.inStock }
|
|
228
|
+
: {}),
|
|
229
|
+
...(includeAdvanced && query.attributes !== undefined
|
|
230
|
+
? { attributes: query.attributes }
|
|
231
|
+
: {}),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Basic fields the canonical `/store/products` endpoint actually accepts. A
|
|
237
|
+
* basic demonstration's `appliedQuery` is limited to these; price/stock/
|
|
238
|
+
* attribute filters are never recorded as applied by the basic endpoint because
|
|
239
|
+
* it does not accept them.
|
|
240
|
+
*/
|
|
241
|
+
function buildBasicDemonstratedQuery(query: CatalogQuery): CatalogQuery {
|
|
242
|
+
return {
|
|
243
|
+
...(query.text !== undefined ? { text: query.text } : {}),
|
|
244
|
+
...(query.categoryScope !== undefined
|
|
245
|
+
? { categoryScope: query.categoryScope }
|
|
246
|
+
: {}),
|
|
247
|
+
...(query.categories !== undefined ? { categories: query.categories } : {}),
|
|
248
|
+
...(query.collections !== undefined
|
|
249
|
+
? { collections: query.collections }
|
|
250
|
+
: {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The optional advanced listing endpoint. Medusa's Store `/store/products`
|
|
256
|
+
* endpoint (the equivalent basic endpoint) applies category/collection/text/
|
|
257
|
+
* sort/pagination only; price, stock, and attribute filters require this
|
|
258
|
+
* separate search plugin (E2 Decision 5). Its presence is proven by a real
|
|
259
|
+
* request, never by a capability boolean or returned facet metadata.
|
|
260
|
+
*/
|
|
261
|
+
const ADVANCED_SEARCH_ENDPOINT = '/store/search';
|
|
262
|
+
|
|
263
|
+
/** The canonical basic listing endpoint that serves a basic-only request. */
|
|
264
|
+
const BASIC_LISTING_ENDPOINT = '/store/products';
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Canonical query fields that require the optional advanced endpoint. Empty
|
|
268
|
+
* means the request is basic-only and can be served by `/store/products`.
|
|
269
|
+
*/
|
|
270
|
+
function collectRequestedAdvancedFields(query: CatalogQuery): string[] {
|
|
271
|
+
const fields: string[] = [];
|
|
272
|
+
if (query.priceMin !== undefined) fields.push('priceMin');
|
|
273
|
+
if (query.priceMax !== undefined) fields.push('priceMax');
|
|
274
|
+
if (query.inStock !== undefined) fields.push('inStock');
|
|
275
|
+
for (const name of Object.keys(query.attributes ?? {})) {
|
|
276
|
+
fields.push(`attr.${name}`);
|
|
277
|
+
}
|
|
278
|
+
return fields;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Map a requested advanced field name to the canonical facet id it needs. */
|
|
282
|
+
function advancedFieldToFacet(field: string): CatalogFacetId {
|
|
283
|
+
if (field === 'priceMin' || field === 'priceMax') return 'price';
|
|
284
|
+
return field as CatalogFacetId;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Advanced fields requested through the legacy `searchProductsAdvanced` options.
|
|
289
|
+
* Empty means the caller asked for a basic-only search that `getProducts` can
|
|
290
|
+
* faithfully serve as a fallback.
|
|
291
|
+
*/
|
|
292
|
+
function collectAdvancedSearchRequestFields(
|
|
293
|
+
options?: AdvancedSearchProductsOptions,
|
|
294
|
+
): string[] {
|
|
295
|
+
const fields: string[] = [];
|
|
296
|
+
if (options?.priceMin !== undefined) fields.push('priceMin');
|
|
297
|
+
if (options?.priceMax !== undefined) fields.push('priceMax');
|
|
298
|
+
if (options?.inStockOnly === true) fields.push('inStock');
|
|
299
|
+
if (options?.attributes && options.attributes.length > 0) {
|
|
300
|
+
fields.push('attributes');
|
|
301
|
+
}
|
|
302
|
+
return fields;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Parse a value the backend may serialize as a number or a numeric string into
|
|
307
|
+
* a finite number, or `undefined`. Shared by the applied-filter readers so a
|
|
308
|
+
* non-finite or missing bound is never treated as an applied filter.
|
|
309
|
+
*/
|
|
310
|
+
function toFiniteNumber(value: unknown): number | undefined {
|
|
311
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
312
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
313
|
+
const parsed = Number(value);
|
|
314
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Read the real backend's `applied_filters.price_range` object (`{ min, max }`,
|
|
321
|
+
* or `null` when no price filter was applied) into finite bounds. A missing,
|
|
322
|
+
* non-object, or non-finite bound yields `undefined` so the price facet is never
|
|
323
|
+
* fabricated (E2 Decision 2: price requires a finite, ordered range).
|
|
324
|
+
*/
|
|
325
|
+
function readAppliedPriceRange(
|
|
326
|
+
applied: Record<string, unknown>,
|
|
327
|
+
): { min?: number; max?: number } {
|
|
328
|
+
const range = applied.price_range;
|
|
329
|
+
if (!range || typeof range !== 'object' || Array.isArray(range)) return {};
|
|
330
|
+
const record = range as Record<string, unknown>;
|
|
331
|
+
return { min: toFiniteNumber(record.min), max: toFiniteNumber(record.max) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Read the filters a real advanced-endpoint response echoes as applied. Only
|
|
336
|
+
* explicit `applied_filters`/`appliedFilters` keys count as proof; a response
|
|
337
|
+
* that omits them demonstrates nothing and the facet stays held (E2 Decision 5:
|
|
338
|
+
* returned facet metadata is not proof).
|
|
339
|
+
*
|
|
340
|
+
* The real backend emits `price_range` (object) and `is_available` (boolean);
|
|
341
|
+
* the documented legacy aliases (`price`/`price_min`/`price_max`,
|
|
342
|
+
* `inStock`/`isAvailable`/`available`) stay recognized (E2 Decision 1).
|
|
343
|
+
*/
|
|
344
|
+
function mapAppliedFiltersToFacets(
|
|
345
|
+
applied: Record<string, unknown>,
|
|
346
|
+
): Set<CatalogFacetId> {
|
|
347
|
+
const facets = new Set<CatalogFacetId>();
|
|
348
|
+
const priceRange = readAppliedPriceRange(applied);
|
|
349
|
+
const hasAppliedPriceRange =
|
|
350
|
+
priceRange.min !== undefined && priceRange.max !== undefined;
|
|
351
|
+
if (
|
|
352
|
+
hasAppliedPriceRange ||
|
|
353
|
+
'price' in applied ||
|
|
354
|
+
'price_min' in applied ||
|
|
355
|
+
'price_max' in applied
|
|
356
|
+
) {
|
|
357
|
+
facets.add('price');
|
|
358
|
+
}
|
|
359
|
+
if (
|
|
360
|
+
typeof applied.is_available === 'boolean' ||
|
|
361
|
+
'inStock' in applied ||
|
|
362
|
+
'isAvailable' in applied ||
|
|
363
|
+
'available' in applied
|
|
364
|
+
) {
|
|
365
|
+
facets.add('inStock');
|
|
366
|
+
}
|
|
367
|
+
const attributes = applied.attributes;
|
|
368
|
+
if (attributes && typeof attributes === 'object' && !Array.isArray(attributes)) {
|
|
369
|
+
for (const name of Object.keys(attributes)) {
|
|
370
|
+
facets.add(`attr.${name}` as CatalogFacetId);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return facets;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Requested advanced fields the actual advanced-endpoint response did not
|
|
378
|
+
* confirm as applied. A probe echo alone cannot guarantee the listing request
|
|
379
|
+
* applied the filter, so the executed response is re-validated against every
|
|
380
|
+
* requested field (value equality where the backend echoes a scalar) before a
|
|
381
|
+
* result is reported as success (E2 task 2.4).
|
|
382
|
+
*/
|
|
383
|
+
function collectUnappliedAdvancedFields(
|
|
384
|
+
applied: Record<string, unknown>,
|
|
385
|
+
query: CatalogQuery,
|
|
386
|
+
): string[] {
|
|
387
|
+
const unapplied: string[] = [];
|
|
388
|
+
|
|
389
|
+
const priceRange = readAppliedPriceRange(applied);
|
|
390
|
+
|
|
391
|
+
if (query.priceMin !== undefined) {
|
|
392
|
+
const echoed = toFiniteNumber(
|
|
393
|
+
priceRange.min ?? applied.price_min ?? applied.priceMin,
|
|
394
|
+
);
|
|
395
|
+
if (echoed !== query.priceMin) unapplied.push('priceMin');
|
|
396
|
+
}
|
|
397
|
+
if (query.priceMax !== undefined) {
|
|
398
|
+
const echoed = toFiniteNumber(
|
|
399
|
+
priceRange.max ?? applied.price_max ?? applied.priceMax,
|
|
400
|
+
);
|
|
401
|
+
if (echoed !== query.priceMax) unapplied.push('priceMax');
|
|
402
|
+
}
|
|
403
|
+
if (query.inStock === true) {
|
|
404
|
+
const echoed =
|
|
405
|
+
applied.is_available ??
|
|
406
|
+
applied.isAvailable ??
|
|
407
|
+
applied.inStock ??
|
|
408
|
+
applied.available;
|
|
409
|
+
if (echoed !== true && echoed !== 'true') unapplied.push('inStock');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const requestedAttributes = query.attributes ?? {};
|
|
413
|
+
const appliedAttributes = applied.attributes;
|
|
414
|
+
for (const name of Object.keys(requestedAttributes)) {
|
|
415
|
+
const hasName =
|
|
416
|
+
!!appliedAttributes &&
|
|
417
|
+
typeof appliedAttributes === 'object' &&
|
|
418
|
+
!Array.isArray(appliedAttributes) &&
|
|
419
|
+
name in (appliedAttributes as Record<string, unknown>);
|
|
420
|
+
if (!hasName) unapplied.push(`attr.${name}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return unapplied;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Read a backend-supplied numeric facet value (range bound or option count).
|
|
428
|
+
*
|
|
429
|
+
* Returns `undefined` when the backend did not supply a finite number, so
|
|
430
|
+
* callers can omit the value entirely instead of inventing a default. This is
|
|
431
|
+
* the guard that keeps `getAvailableFilters` from fabricating ranges or counts
|
|
432
|
+
* (E2 Decision 4; spec "Facet counts and ranges are never fabricated").
|
|
433
|
+
*/
|
|
434
|
+
function readFiniteFacetNumber(value: unknown): number | undefined {
|
|
435
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
436
|
+
? value
|
|
437
|
+
: undefined;
|
|
438
|
+
}
|
|
60
439
|
|
|
61
440
|
/**
|
|
62
441
|
* Medusa provider configuration
|
|
@@ -85,6 +464,13 @@ export interface MedusaProviderConfig extends ProviderConfig {
|
|
|
85
464
|
*/
|
|
86
465
|
regionId?: string;
|
|
87
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Per-request catalog listing bound. Listing is paginated with this bound
|
|
469
|
+
* instead of an unbounded scan; it is a configured safety bound, not a
|
|
470
|
+
* backend maximum. Defaults to `MEDUSA_CATALOG_PAGE_SIZE` or 50.
|
|
471
|
+
*/
|
|
472
|
+
catalogPageSize?: number;
|
|
473
|
+
|
|
88
474
|
/**
|
|
89
475
|
* Authentication configuration (optional)
|
|
90
476
|
* If not provided, auth methods will not be available
|
|
@@ -126,10 +512,23 @@ export interface MedusaProviderConfig extends ProviderConfig {
|
|
|
126
512
|
*
|
|
127
513
|
* Implements CommerceProvider interface for Medusa.js v2
|
|
128
514
|
*/
|
|
129
|
-
export class MedusaProvider implements CommerceProvider {
|
|
515
|
+
export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
|
|
130
516
|
readonly name = 'medusa';
|
|
131
517
|
readonly version = '2.0.0';
|
|
132
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Canonical catalog sort keys this provider applies at the query level.
|
|
521
|
+
*
|
|
522
|
+
* Advertises only the backend-verified orderings (E2 Decision 6). `price-asc`,
|
|
523
|
+
* `price-desc`, and `rating` are intentionally absent: they are not applied at
|
|
524
|
+
* the query level, so the listing never offers them as global sorts.
|
|
525
|
+
*
|
|
526
|
+
* The literal tuple is assignable to `CommerceProvider['supportedCatalogSortKeys']`
|
|
527
|
+
* (`readonly CatalogSortKey[]`); it is written structurally here so the adapter
|
|
528
|
+
* compiles against the published `@thorprovider/types` declaration.
|
|
529
|
+
*/
|
|
530
|
+
readonly supportedCatalogSortKeys = ['featured', 'name', 'newest'] as const;
|
|
531
|
+
|
|
133
532
|
/**
|
|
134
533
|
* Medusa backend capabilities.
|
|
135
534
|
* Starts with static defaults, replaced by dynamic values from
|
|
@@ -154,6 +553,15 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
154
553
|
private baseUrl: string;
|
|
155
554
|
private publishableKey: string;
|
|
156
555
|
private regionId?: string;
|
|
556
|
+
/** Configured per-request catalog listing bound (never an unbounded scan). */
|
|
557
|
+
private catalogPageSize: number;
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Per-scope cache of the truthful facet-support descriptor. The optional
|
|
561
|
+
* advanced endpoint is probed once per demonstrated scope/query rather than on
|
|
562
|
+
* every listing request (E2 task 2.4).
|
|
563
|
+
*/
|
|
564
|
+
private facetSupportCache = new Map<string, Promise<FacetSupport>>();
|
|
157
565
|
|
|
158
566
|
/** Secret API key for admin operations (if configured). */
|
|
159
567
|
private adminApiKey?: string;
|
|
@@ -212,6 +620,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
212
620
|
this.baseUrl = config.baseUrl;
|
|
213
621
|
this.publishableKey = config.publishableKey;
|
|
214
622
|
this.regionId = config.regionId;
|
|
623
|
+
this.catalogPageSize = resolveCatalogPageSize(config.catalogPageSize);
|
|
215
624
|
|
|
216
625
|
// Store retry configuration
|
|
217
626
|
this.retryConfig = {
|
|
@@ -238,11 +647,22 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
238
647
|
// Always use JWT mode for auth - SDK needs to send Bearer tokens
|
|
239
648
|
// Configure storage method for JWT tokens (browser only)
|
|
240
649
|
auth: config.auth
|
|
241
|
-
?
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
650
|
+
? config.auth.customStorage
|
|
651
|
+
? {
|
|
652
|
+
type: 'jwt',
|
|
653
|
+
// Request-scoped custom storage: the caller supplies a per-request
|
|
654
|
+
// token holder (for example a server request's customer JWT). The
|
|
655
|
+
// SDK reads the token on every request and writes the login /
|
|
656
|
+
// registration token back into the same holder, so a fresh
|
|
657
|
+
// provider per request is authenticated without shared state.
|
|
658
|
+
jwtTokenStorageMethod: 'custom',
|
|
659
|
+
storage: config.auth.customStorage,
|
|
660
|
+
}
|
|
661
|
+
: {
|
|
662
|
+
type: 'jwt',
|
|
663
|
+
// Use localStorage in browser, memory storage on server
|
|
664
|
+
jwtTokenStorageMethod: typeof window !== 'undefined' ? 'local' : 'memory',
|
|
665
|
+
}
|
|
246
666
|
: undefined,
|
|
247
667
|
});
|
|
248
668
|
|
|
@@ -385,45 +805,65 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
385
805
|
throw lastError;
|
|
386
806
|
}
|
|
387
807
|
|
|
388
|
-
|
|
389
|
-
|
|
808
|
+
/** Fields needed to price a product variant for the current region. */
|
|
809
|
+
private buildProductFields(regionId?: string): string {
|
|
810
|
+
// calculated_price requires region_id in Medusa v2 — only request it when available
|
|
811
|
+
const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags,+categories.*';
|
|
812
|
+
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
813
|
+
return regionId ? baseFields + pricingFields : baseFields;
|
|
814
|
+
}
|
|
390
815
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const fields = regionId ? baseFields + pricingFields : baseFields;
|
|
816
|
+
/** Scope a store query to the configured storefront sales channel, if any. */
|
|
817
|
+
private applySalesChannelScope(queryParams: Record<string, unknown>): void {
|
|
818
|
+
const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
819
|
+
if (salesChannelId) {
|
|
820
|
+
queryParams.sales_channel_id = [salesChannelId];
|
|
821
|
+
}
|
|
822
|
+
}
|
|
399
823
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
824
|
+
/**
|
|
825
|
+
* Directed single-product lookup by a supported native filter (`handle` or
|
|
826
|
+
* `id`). This is independent of catalog listing pagination, so a known
|
|
827
|
+
* product beyond the first listing page still resolves without scanning.
|
|
828
|
+
*
|
|
829
|
+
* Medusa's store API supports `handle` and `id` on `/store/products`; it
|
|
830
|
+
* does not expose a variant filter there, so a variant is resolved from the
|
|
831
|
+
* product once the product is known (see `getProductDetailByHandle` in the
|
|
832
|
+
* runtime). No `limit` beyond 1 is issued.
|
|
833
|
+
*/
|
|
834
|
+
private async fetchSingleProduct(
|
|
835
|
+
filter: { handle?: string; id?: string },
|
|
836
|
+
options?: { regionId?: string },
|
|
837
|
+
): Promise<Product | undefined> {
|
|
838
|
+
const regionId = options?.regionId ?? this.regionId;
|
|
839
|
+
|
|
840
|
+
const queryParams: Record<string, unknown> = {
|
|
841
|
+
...filter,
|
|
842
|
+
limit: 1,
|
|
843
|
+
fields: this.buildProductFields(regionId),
|
|
844
|
+
};
|
|
845
|
+
// Add region_id for region-based pricing
|
|
846
|
+
if (regionId) {
|
|
847
|
+
queryParams.region_id = regionId;
|
|
848
|
+
}
|
|
849
|
+
this.applySalesChannelScope(queryParams);
|
|
409
850
|
|
|
410
|
-
|
|
411
|
-
const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
412
|
-
if (salesChannelId) {
|
|
413
|
-
queryParams.sales_channel_id = [salesChannelId];
|
|
414
|
-
}
|
|
851
|
+
const { products } = await this.sdk.store.product.list(queryParams as any);
|
|
415
852
|
|
|
416
|
-
|
|
853
|
+
if (!products || products.length === 0) {
|
|
854
|
+
this.logger.debug('Product not found', filter);
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
417
857
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return undefined;
|
|
421
|
-
}
|
|
858
|
+
return transformProduct(products[0], this.currencyCode);
|
|
859
|
+
}
|
|
422
860
|
|
|
423
|
-
|
|
861
|
+
async getProduct(handle: string, options?: { regionId?: string }): Promise<Product | undefined> {
|
|
862
|
+
this.logger.debug('getProduct', { handle, regionId: options?.regionId });
|
|
424
863
|
|
|
425
|
-
|
|
426
|
-
|
|
864
|
+
try {
|
|
865
|
+
return await this.withRetry(
|
|
866
|
+
() => this.fetchSingleProduct({ handle }, options),
|
|
427
867
|
'getProduct',
|
|
428
868
|
{ handle, regionId: options?.regionId },
|
|
429
869
|
);
|
|
@@ -436,6 +876,31 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
436
876
|
}
|
|
437
877
|
}
|
|
438
878
|
|
|
879
|
+
/**
|
|
880
|
+
* Directed product lookup by backend product ID. Independent of the first
|
|
881
|
+
* listing page, mirroring `getProduct` for handle-based resolution.
|
|
882
|
+
*/
|
|
883
|
+
async getProductById(
|
|
884
|
+
id: string,
|
|
885
|
+
options?: { regionId?: string },
|
|
886
|
+
): Promise<Product | undefined> {
|
|
887
|
+
this.logger.debug('getProductById', { id, regionId: options?.regionId });
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
return await this.withRetry(
|
|
891
|
+
() => this.fetchSingleProduct({ id }, options),
|
|
892
|
+
'getProductById',
|
|
893
|
+
{ id, regionId: options?.regionId },
|
|
894
|
+
);
|
|
895
|
+
} catch (error: any) {
|
|
896
|
+
throw new ProviderAPIError(
|
|
897
|
+
`Failed to fetch product by id: ${error.message}`,
|
|
898
|
+
'medusa',
|
|
899
|
+
error.status,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
439
904
|
async getProducts(options: GetProductsOptions & { regionId?: string } = {}): Promise<Product[]> {
|
|
440
905
|
this.logger.debug('getProducts', options);
|
|
441
906
|
|
|
@@ -448,28 +913,25 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
448
913
|
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
449
914
|
const fields = regionId ? baseFields + pricingFields : baseFields;
|
|
450
915
|
|
|
451
|
-
// Map Thor
|
|
452
|
-
// Native endpoint supports: title, -title, created_at, -created_at
|
|
453
|
-
// Price sorting
|
|
454
|
-
//
|
|
455
|
-
const SORT_KEY_MAP: Record<string, string | null> = {
|
|
456
|
-
featured: null, // server default order
|
|
457
|
-
name: 'title', // A→Z by product title
|
|
458
|
-
newest: '-created_at', // newest products first
|
|
459
|
-
'price-asc': null, // not supported by native endpoint → client-side
|
|
460
|
-
'price-desc': null, // not supported by native endpoint → client-side
|
|
461
|
-
rating: null, // not supported → server default
|
|
462
|
-
};
|
|
916
|
+
// Map Thor sort keys to Medusa /store/products `order` field names.
|
|
917
|
+
// Native endpoint supports: title, -title, created_at, -created_at.
|
|
918
|
+
// Price/rating sorting is not supported — omitted so products-client
|
|
919
|
+
// handles it client-side as before (see MEDUSA_SORT_KEY_MAP).
|
|
463
920
|
|
|
921
|
+
// Bounded, paginated listing (change B, decision 5): the per-request
|
|
922
|
+
// limit is the configured catalog bound, never an unbounded scan.
|
|
464
923
|
const queryParams: any = {
|
|
465
924
|
q: options.query,
|
|
466
|
-
limit: options.first ??
|
|
467
|
-
offset:
|
|
925
|
+
limit: options.first ?? this.catalogPageSize,
|
|
926
|
+
offset:
|
|
927
|
+
options.after !== undefined
|
|
928
|
+
? parseInt(String(options.after), 10)
|
|
929
|
+
: options.skip,
|
|
468
930
|
fields,
|
|
469
931
|
};
|
|
470
932
|
|
|
471
933
|
if (options.sortKey) {
|
|
472
|
-
const mappedOrder =
|
|
934
|
+
const mappedOrder = MEDUSA_SORT_KEY_MAP[options.sortKey] ?? null;
|
|
473
935
|
if (mappedOrder !== null) {
|
|
474
936
|
queryParams.order = mappedOrder;
|
|
475
937
|
}
|
|
@@ -524,6 +986,617 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
524
986
|
}
|
|
525
987
|
}
|
|
526
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Canonical catalog query path (E2 task 2.2).
|
|
991
|
+
*
|
|
992
|
+
* Accepts a validated `CatalogQuery`, translates it into Medusa Store
|
|
993
|
+
* `/store/products` parameters, and returns a truthful `CatalogQueryOutcome`.
|
|
994
|
+
* The route category scope is always retained and intersected with the
|
|
995
|
+
* `categories` filter; `categories`/`collections` handles are resolved to
|
|
996
|
+
* backend IDs through the existing directed lookups (`getCategory`/
|
|
997
|
+
* `getCollection`). Any requested filter this provider cannot apply
|
|
998
|
+
* (price/`inStock`/attributes) is rejected as a typed `unsupported-query`
|
|
999
|
+
* outcome naming the fields — never a partial success and never a silent
|
|
1000
|
+
* drop. Pagination is `offset = (page - 1) * pageSize`, defaulting to the
|
|
1001
|
+
* canonical page size.
|
|
1002
|
+
*
|
|
1003
|
+
* Legacy `getProducts`/`getCategoryProducts` are unchanged; this additive
|
|
1004
|
+
* method is the typed seam the runtime query bridge (E2 task 3.1) consumes.
|
|
1005
|
+
*/
|
|
1006
|
+
async getCatalogProducts(query: CatalogQuery): Promise<CatalogQueryOutcome> {
|
|
1007
|
+
this.logger.debug('getCatalogProducts', query);
|
|
1008
|
+
|
|
1009
|
+
// Sort support is advertised explicitly by the adapter; an unadvertised
|
|
1010
|
+
// sort is rejected before any provider call.
|
|
1011
|
+
if (
|
|
1012
|
+
query.sort !== undefined &&
|
|
1013
|
+
!this.supportedCatalogSortKeys.includes(query.sort)
|
|
1014
|
+
) {
|
|
1015
|
+
return this.unsupportedCatalogOutcome(['sort']);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const effectiveCategoryHandles = resolveEffectiveCategoryHandles(query);
|
|
1019
|
+
|
|
1020
|
+
try {
|
|
1021
|
+
const categoryIds =
|
|
1022
|
+
effectiveCategoryHandles !== undefined
|
|
1023
|
+
? await this.resolveCategoryIds(effectiveCategoryHandles)
|
|
1024
|
+
: undefined;
|
|
1025
|
+
const collectionIds = query.collections
|
|
1026
|
+
? await this.resolveCollectionIds(query.collections)
|
|
1027
|
+
: undefined;
|
|
1028
|
+
|
|
1029
|
+
// The truthful support descriptor for this scope, proven against the real
|
|
1030
|
+
// optional endpoint. It is attached to every successful result.
|
|
1031
|
+
const facetSupport = await this.getCatalogFacetSupport({
|
|
1032
|
+
query,
|
|
1033
|
+
categoryIds,
|
|
1034
|
+
collectionIds,
|
|
1035
|
+
scope: query.categoryScope,
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// Reject every requested advanced field that was not demonstrated BEFORE
|
|
1039
|
+
// any early zero-result return, so an unsupported filter is never hidden
|
|
1040
|
+
// behind an empty category intersection or an unresolved handle.
|
|
1041
|
+
const requestedAdvanced = collectRequestedAdvancedFields(query);
|
|
1042
|
+
const heldAdvanced = requestedAdvanced.filter(
|
|
1043
|
+
(field) => !facetSupport.offered.includes(advancedFieldToFacet(field)),
|
|
1044
|
+
);
|
|
1045
|
+
if (heldAdvanced.length > 0) {
|
|
1046
|
+
return this.unsupportedCatalogOutcome(heldAdvanced);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// A scope that matches nothing (explicit empty route-scope ∩ categories
|
|
1050
|
+
// intersection, or a requested category/collection that resolves to no
|
|
1051
|
+
// backend ID) is a valid zero-result query — never an unfiltered request.
|
|
1052
|
+
// Requested+offered advanced filters are included as applied because the
|
|
1053
|
+
// scope already excludes every product; they are not silently dropped.
|
|
1054
|
+
const emptyIntersection =
|
|
1055
|
+
effectiveCategoryHandles !== undefined &&
|
|
1056
|
+
effectiveCategoryHandles.length === 0;
|
|
1057
|
+
const categoryFilterEmpty =
|
|
1058
|
+
effectiveCategoryHandles !== undefined &&
|
|
1059
|
+
(categoryIds?.length ?? 0) === 0;
|
|
1060
|
+
const collectionFilterEmpty =
|
|
1061
|
+
query.collections !== undefined && (collectionIds?.length ?? 0) === 0;
|
|
1062
|
+
if (emptyIntersection || categoryFilterEmpty || collectionFilterEmpty) {
|
|
1063
|
+
return {
|
|
1064
|
+
ok: true,
|
|
1065
|
+
results: {
|
|
1066
|
+
products: [],
|
|
1067
|
+
appliedFilters: buildAppliedCatalogFilters(
|
|
1068
|
+
query,
|
|
1069
|
+
effectiveCategoryHandles,
|
|
1070
|
+
requestedAdvanced.length > 0,
|
|
1071
|
+
),
|
|
1072
|
+
facetSupport,
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// When advanced filters were requested and demonstrated, the request is
|
|
1078
|
+
// executed against the real advanced endpoint and re-validated there;
|
|
1079
|
+
// it is never served by the basic endpoint with the filters dropped.
|
|
1080
|
+
if (requestedAdvanced.length > 0) {
|
|
1081
|
+
return this.queryAdvancedCatalog(
|
|
1082
|
+
query,
|
|
1083
|
+
categoryIds,
|
|
1084
|
+
collectionIds,
|
|
1085
|
+
facetSupport,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return await this.queryBasicCatalog(
|
|
1090
|
+
query,
|
|
1091
|
+
effectiveCategoryHandles,
|
|
1092
|
+
categoryIds,
|
|
1093
|
+
collectionIds,
|
|
1094
|
+
facetSupport,
|
|
1095
|
+
);
|
|
1096
|
+
} catch (error: any) {
|
|
1097
|
+
this.logger.error('getCatalogProducts error details', {
|
|
1098
|
+
message: error.message,
|
|
1099
|
+
status: error.status,
|
|
1100
|
+
});
|
|
1101
|
+
throw new ProviderAPIError(
|
|
1102
|
+
`Failed to run catalog query: ${error.message || 'Unknown error'}`,
|
|
1103
|
+
'medusa',
|
|
1104
|
+
error.status,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Execute a basic-only canonical query against the equivalent basic endpoint
|
|
1111
|
+
* `/store/products`, reporting the filters it actually applied.
|
|
1112
|
+
*/
|
|
1113
|
+
private async queryBasicCatalog(
|
|
1114
|
+
query: CatalogQuery,
|
|
1115
|
+
effectiveCategoryHandles: string[] | undefined,
|
|
1116
|
+
categoryIds: string[] | undefined,
|
|
1117
|
+
collectionIds: string[] | undefined,
|
|
1118
|
+
facetSupport: FacetSupport,
|
|
1119
|
+
): Promise<CatalogQueryOutcome> {
|
|
1120
|
+
const pageSize = query.pageSize ?? this.catalogPageSize;
|
|
1121
|
+
const page = query.page ?? 1;
|
|
1122
|
+
const regionId = this.regionId;
|
|
1123
|
+
const baseFields =
|
|
1124
|
+
'+variants,+variants.inventory_quantity,+options,+images,+thumbnail,+tags';
|
|
1125
|
+
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
1126
|
+
|
|
1127
|
+
const queryParams: any = {
|
|
1128
|
+
limit: pageSize,
|
|
1129
|
+
offset: (page - 1) * pageSize,
|
|
1130
|
+
fields: regionId ? baseFields + pricingFields : baseFields,
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
if (query.text) {
|
|
1134
|
+
queryParams.q = query.text;
|
|
1135
|
+
}
|
|
1136
|
+
if (categoryIds && categoryIds.length > 0) {
|
|
1137
|
+
queryParams.category_id = categoryIds;
|
|
1138
|
+
}
|
|
1139
|
+
if (collectionIds && collectionIds.length > 0) {
|
|
1140
|
+
queryParams.collection_id = collectionIds;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Canonical sort → Medusa `order`; `null` omits the parameter entirely.
|
|
1144
|
+
if (query.sort) {
|
|
1145
|
+
const mappedOrder = MEDUSA_SORT_KEY_MAP[query.sort] ?? null;
|
|
1146
|
+
if (mappedOrder !== null) {
|
|
1147
|
+
queryParams.order = mappedOrder;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (regionId) {
|
|
1152
|
+
queryParams.region_id = regionId;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
1156
|
+
if (salesChannelId) {
|
|
1157
|
+
queryParams.sales_channel_id = [salesChannelId];
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const response = (await this.sdk.store.product.list(queryParams)) as any;
|
|
1161
|
+
const products = (response.products ?? []).map((p: any) =>
|
|
1162
|
+
transformProduct(p, this.currencyCode),
|
|
1163
|
+
);
|
|
1164
|
+
const count = response.count;
|
|
1165
|
+
|
|
1166
|
+
return {
|
|
1167
|
+
ok: true,
|
|
1168
|
+
results: {
|
|
1169
|
+
products,
|
|
1170
|
+
// Total is only the backend's actual count; never manufactured.
|
|
1171
|
+
...(typeof count === 'number' ? { total: count } : {}),
|
|
1172
|
+
appliedFilters: buildAppliedCatalogFilters(
|
|
1173
|
+
query,
|
|
1174
|
+
effectiveCategoryHandles,
|
|
1175
|
+
),
|
|
1176
|
+
facetSupport,
|
|
1177
|
+
},
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Execute an advanced canonical query against the real optional endpoint
|
|
1183
|
+
* `/store/search`, then re-validate that the response applied every requested
|
|
1184
|
+
* filter. A response that does not confirm a requested field, or an endpoint
|
|
1185
|
+
* failure, yields a typed `unsupported-query` error — never a partial result
|
|
1186
|
+
* labelled success and never a silent fallback to the basic endpoint.
|
|
1187
|
+
*/
|
|
1188
|
+
private async queryAdvancedCatalog(
|
|
1189
|
+
query: CatalogQuery,
|
|
1190
|
+
categoryIds: string[] | undefined,
|
|
1191
|
+
collectionIds: string[] | undefined,
|
|
1192
|
+
facetSupport: FacetSupport,
|
|
1193
|
+
): Promise<CatalogQueryOutcome> {
|
|
1194
|
+
const pageSize = query.pageSize ?? this.catalogPageSize;
|
|
1195
|
+
const page = query.page ?? 1;
|
|
1196
|
+
const advancedQuery = this.buildAdvancedSearchQuery(
|
|
1197
|
+
query,
|
|
1198
|
+
categoryIds ?? [],
|
|
1199
|
+
collectionIds ?? [],
|
|
1200
|
+
);
|
|
1201
|
+
advancedQuery.limit = pageSize;
|
|
1202
|
+
advancedQuery.offset = (page - 1) * pageSize;
|
|
1203
|
+
|
|
1204
|
+
let data: any;
|
|
1205
|
+
try {
|
|
1206
|
+
// Custom route: use the SDK client so the publishable API key and headers
|
|
1207
|
+
// are applied automatically (building-storefronts `sdk-client-fetch`).
|
|
1208
|
+
data = await this.sdk.client.fetch(ADVANCED_SEARCH_ENDPOINT, {
|
|
1209
|
+
query: advancedQuery,
|
|
1210
|
+
});
|
|
1211
|
+
} catch (error: any) {
|
|
1212
|
+
this.logger.error('Advanced catalog listing failed', {
|
|
1213
|
+
message: error?.message,
|
|
1214
|
+
status: error?.status,
|
|
1215
|
+
});
|
|
1216
|
+
// Do not fall back to the basic endpoint: that would silently drop the
|
|
1217
|
+
// requested advanced filters.
|
|
1218
|
+
return this.unsupportedCatalogOutcome(
|
|
1219
|
+
collectRequestedAdvancedFields(query),
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const appliedRaw = data?.applied_filters ?? data?.appliedFilters;
|
|
1224
|
+
if (!appliedRaw || typeof appliedRaw !== 'object') {
|
|
1225
|
+
this.logger.warn('Advanced endpoint did not echo applied filters', {
|
|
1226
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1227
|
+
});
|
|
1228
|
+
return this.unsupportedCatalogOutcome(
|
|
1229
|
+
collectRequestedAdvancedFields(query),
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const unapplied = collectUnappliedAdvancedFields(
|
|
1234
|
+
appliedRaw as Record<string, unknown>,
|
|
1235
|
+
query,
|
|
1236
|
+
);
|
|
1237
|
+
if (unapplied.length > 0) {
|
|
1238
|
+
this.logger.warn('Advanced endpoint did not apply requested filters', {
|
|
1239
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1240
|
+
fields: unapplied,
|
|
1241
|
+
});
|
|
1242
|
+
return this.unsupportedCatalogOutcome(unapplied);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const products = (data.hits ?? []).map((hit: any) =>
|
|
1246
|
+
transformProduct(hit, this.currencyCode),
|
|
1247
|
+
);
|
|
1248
|
+
const total =
|
|
1249
|
+
typeof data.estimatedTotalHits === 'number'
|
|
1250
|
+
? data.estimatedTotalHits
|
|
1251
|
+
: typeof data.count === 'number'
|
|
1252
|
+
? data.count
|
|
1253
|
+
: undefined;
|
|
1254
|
+
|
|
1255
|
+
return {
|
|
1256
|
+
ok: true,
|
|
1257
|
+
results: {
|
|
1258
|
+
products,
|
|
1259
|
+
...(total !== undefined ? { total } : {}),
|
|
1260
|
+
appliedFilters: buildAppliedCatalogFilters(
|
|
1261
|
+
query,
|
|
1262
|
+
resolveEffectiveCategoryHandles(query),
|
|
1263
|
+
true,
|
|
1264
|
+
),
|
|
1265
|
+
facetSupport,
|
|
1266
|
+
},
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** Resolve category handles to Medusa category IDs via directed lookups. */
|
|
1271
|
+
private async resolveCategoryIds(handles: string[]): Promise<string[]> {
|
|
1272
|
+
const ids: string[] = [];
|
|
1273
|
+
for (const handle of handles) {
|
|
1274
|
+
const category = await this.getCategory(handle);
|
|
1275
|
+
if (category) ids.push(category.id);
|
|
1276
|
+
}
|
|
1277
|
+
return ids;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Resolve collection handles to Medusa collection IDs via directed lookups. */
|
|
1281
|
+
private async resolveCollectionIds(handles: string[]): Promise<string[]> {
|
|
1282
|
+
const ids: string[] = [];
|
|
1283
|
+
for (const handle of handles) {
|
|
1284
|
+
const collection = await this.getCollection(handle);
|
|
1285
|
+
if (collection) ids.push(collection.id);
|
|
1286
|
+
}
|
|
1287
|
+
return ids;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Truthful facet-support descriptor for a scope (E2 task 2.4).
|
|
1292
|
+
*
|
|
1293
|
+
* Basic facets (`categories`, `collections`) are always offered: they are
|
|
1294
|
+
* applied by the canonical basic endpoint `/store/products`, and the
|
|
1295
|
+
* descriptor records that endpoint with the applied query and scope as their
|
|
1296
|
+
* demonstration source.
|
|
1297
|
+
*
|
|
1298
|
+
* Optional advanced facets (`price`, `inStock`, and advertised `attr.<name>`)
|
|
1299
|
+
* are offered only when the real optional `/store/search` endpoint has been
|
|
1300
|
+
* demonstrated with the applied query for the current scope AND the response
|
|
1301
|
+
* echoes the filter as applied. Otherwise they are held with the real probe
|
|
1302
|
+
* status/reason — a capability boolean or returned facet metadata is never
|
|
1303
|
+
* accepted as proof (E2 Decisions 4–5; spec "Backend support is proven against
|
|
1304
|
+
* the real endpoint, not assumed").
|
|
1305
|
+
*
|
|
1306
|
+
* The descriptor carries no counts or ranges and no invented timestamp; every
|
|
1307
|
+
* offered facet has an actual `{ endpoint, appliedQuery, scope }` source.
|
|
1308
|
+
*/
|
|
1309
|
+
async getCatalogFacetSupport(
|
|
1310
|
+
options: {
|
|
1311
|
+
query?: CatalogQuery;
|
|
1312
|
+
categoryIds?: string[];
|
|
1313
|
+
collectionIds?: string[];
|
|
1314
|
+
scope?: string;
|
|
1315
|
+
} = {},
|
|
1316
|
+
): Promise<FacetSupport> {
|
|
1317
|
+
const query = options.query ?? {};
|
|
1318
|
+
const scope = options.scope ?? query.categoryScope;
|
|
1319
|
+
const key = JSON.stringify({
|
|
1320
|
+
scope: scope ?? null,
|
|
1321
|
+
categoryIds: options.categoryIds ?? null,
|
|
1322
|
+
collectionIds: options.collectionIds ?? null,
|
|
1323
|
+
categories: query.categories ?? null,
|
|
1324
|
+
text: query.text ?? null,
|
|
1325
|
+
collections: query.collections ?? null,
|
|
1326
|
+
priceMin: query.priceMin ?? null,
|
|
1327
|
+
priceMax: query.priceMax ?? null,
|
|
1328
|
+
inStock: query.inStock ?? null,
|
|
1329
|
+
attributes: query.attributes ?? null,
|
|
1330
|
+
});
|
|
1331
|
+
const cached = this.facetSupportCache.get(key);
|
|
1332
|
+
if (cached) return cached;
|
|
1333
|
+
const pending = this.resolveCatalogFacetSupport(
|
|
1334
|
+
query,
|
|
1335
|
+
options.categoryIds,
|
|
1336
|
+
options.collectionIds,
|
|
1337
|
+
scope,
|
|
1338
|
+
);
|
|
1339
|
+
this.facetSupportCache.set(key, pending);
|
|
1340
|
+
return pending;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
private async resolveCatalogFacetSupport(
|
|
1344
|
+
query: CatalogQuery,
|
|
1345
|
+
providedCategoryIds: string[] | undefined,
|
|
1346
|
+
providedCollectionIds: string[] | undefined,
|
|
1347
|
+
scope: string | undefined,
|
|
1348
|
+
): Promise<FacetSupport> {
|
|
1349
|
+
// The advanced demonstration records the full applied filter query; the
|
|
1350
|
+
// basic demonstration records only the fields `/store/products` actually
|
|
1351
|
+
// accepts (text/scope/categories/collections). Price/stock/attributes are
|
|
1352
|
+
// never recorded as applied by an endpoint that does not accept them.
|
|
1353
|
+
const advancedDemonstratedQuery: CatalogQuery = {
|
|
1354
|
+
...(query.text !== undefined ? { text: query.text } : {}),
|
|
1355
|
+
...(query.categoryScope !== undefined
|
|
1356
|
+
? { categoryScope: query.categoryScope }
|
|
1357
|
+
: {}),
|
|
1358
|
+
...(query.categories !== undefined ? { categories: query.categories } : {}),
|
|
1359
|
+
...(query.collections !== undefined
|
|
1360
|
+
? { collections: query.collections }
|
|
1361
|
+
: {}),
|
|
1362
|
+
...(query.priceMin !== undefined ? { priceMin: query.priceMin } : {}),
|
|
1363
|
+
...(query.priceMax !== undefined ? { priceMax: query.priceMax } : {}),
|
|
1364
|
+
...(query.inStock !== undefined ? { inStock: query.inStock } : {}),
|
|
1365
|
+
...(query.attributes !== undefined
|
|
1366
|
+
? { attributes: query.attributes }
|
|
1367
|
+
: {}),
|
|
1368
|
+
};
|
|
1369
|
+
const basicDemonstratedQuery = buildBasicDemonstratedQuery(query);
|
|
1370
|
+
const scopeField = scope !== undefined ? { scope } : {};
|
|
1371
|
+
|
|
1372
|
+
const offered: CatalogFacetId[] = ['categories', 'collections'];
|
|
1373
|
+
const demonstrations: Record<string, CatalogFacetDemonstrationSource> = {
|
|
1374
|
+
categories: {
|
|
1375
|
+
endpoint: BASIC_LISTING_ENDPOINT,
|
|
1376
|
+
appliedQuery: basicDemonstratedQuery,
|
|
1377
|
+
...scopeField,
|
|
1378
|
+
},
|
|
1379
|
+
collections: {
|
|
1380
|
+
endpoint: BASIC_LISTING_ENDPOINT,
|
|
1381
|
+
appliedQuery: basicDemonstratedQuery,
|
|
1382
|
+
...scopeField,
|
|
1383
|
+
},
|
|
1384
|
+
};
|
|
1385
|
+
const held: CatalogFacetId[] = [];
|
|
1386
|
+
const heldReasons: Record<string, string> = {};
|
|
1387
|
+
|
|
1388
|
+
const categoryIds =
|
|
1389
|
+
providedCategoryIds ?? (await this.resolveScopeCategoryIds(query));
|
|
1390
|
+
const collectionIds =
|
|
1391
|
+
providedCollectionIds ??
|
|
1392
|
+
(query.collections
|
|
1393
|
+
? await this.resolveCollectionIds(query.collections)
|
|
1394
|
+
: []);
|
|
1395
|
+
const candidates: CatalogFacetId[] = ['price', 'inStock'];
|
|
1396
|
+
for (const handle of await this.resolveFilterableAttributeHandles(
|
|
1397
|
+
categoryIds,
|
|
1398
|
+
)) {
|
|
1399
|
+
const facet = `attr.${handle}` as CatalogFacetId;
|
|
1400
|
+
if (!candidates.includes(facet)) candidates.push(facet);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const probe = await this.probeAdvancedSearchSupport(
|
|
1404
|
+
query,
|
|
1405
|
+
categoryIds,
|
|
1406
|
+
collectionIds,
|
|
1407
|
+
);
|
|
1408
|
+
if (probe.available) {
|
|
1409
|
+
for (const facet of candidates) {
|
|
1410
|
+
if (probe.applied.has(facet)) {
|
|
1411
|
+
offered.push(facet);
|
|
1412
|
+
demonstrations[facet] = {
|
|
1413
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1414
|
+
appliedQuery: advancedDemonstratedQuery,
|
|
1415
|
+
...scopeField,
|
|
1416
|
+
};
|
|
1417
|
+
} else {
|
|
1418
|
+
held.push(facet);
|
|
1419
|
+
heldReasons[facet] =
|
|
1420
|
+
`not echoed as applied by ${ADVANCED_SEARCH_ENDPOINT}`;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
} else {
|
|
1424
|
+
for (const facet of candidates) {
|
|
1425
|
+
held.push(facet);
|
|
1426
|
+
heldReasons[facet] = probe.reason;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
return {
|
|
1431
|
+
offered,
|
|
1432
|
+
held,
|
|
1433
|
+
demonstrations,
|
|
1434
|
+
...(Object.keys(heldReasons).length > 0 ? { heldReasons } : {}),
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Demonstrate the optional advanced endpoint with the applied query for the
|
|
1440
|
+
* current scope. A non-2xx response (including the HTTP 404/500 an absent
|
|
1441
|
+
* plugin returns), an unparseable body, or a response that does not echo the
|
|
1442
|
+
* applied filters all mean the facet is not demonstrated — never a silent
|
|
1443
|
+
* assumption of support. Uses a single SDK request (no retry): a support probe
|
|
1444
|
+
* must not amplify a broken optional endpoint into repeated requests.
|
|
1445
|
+
*/
|
|
1446
|
+
private async probeAdvancedSearchSupport(
|
|
1447
|
+
query: CatalogQuery,
|
|
1448
|
+
categoryIds: string[],
|
|
1449
|
+
collectionIds: string[],
|
|
1450
|
+
): Promise<{
|
|
1451
|
+
available: boolean;
|
|
1452
|
+
applied: Set<CatalogFacetId>;
|
|
1453
|
+
reason: string;
|
|
1454
|
+
status?: number;
|
|
1455
|
+
}> {
|
|
1456
|
+
const advancedQuery = this.buildAdvancedSearchQuery(
|
|
1457
|
+
query,
|
|
1458
|
+
categoryIds,
|
|
1459
|
+
collectionIds,
|
|
1460
|
+
);
|
|
1461
|
+
advancedQuery.limit = 1;
|
|
1462
|
+
try {
|
|
1463
|
+
// Custom route: use the SDK client so the publishable API key and headers
|
|
1464
|
+
// are applied automatically (building-storefronts `sdk-client-fetch`).
|
|
1465
|
+
const data: any = await this.sdk.client.fetch(ADVANCED_SEARCH_ENDPOINT, {
|
|
1466
|
+
query: advancedQuery,
|
|
1467
|
+
});
|
|
1468
|
+
const appliedRaw = data?.applied_filters ?? data?.appliedFilters;
|
|
1469
|
+
if (!appliedRaw || typeof appliedRaw !== 'object') {
|
|
1470
|
+
return {
|
|
1471
|
+
available: false,
|
|
1472
|
+
applied: new Set(),
|
|
1473
|
+
reason: `${ADVANCED_SEARCH_ENDPOINT} did not echo applied filters; support unverified`,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
return {
|
|
1477
|
+
available: true,
|
|
1478
|
+
applied: mapAppliedFiltersToFacets(
|
|
1479
|
+
appliedRaw as Record<string, unknown>,
|
|
1480
|
+
),
|
|
1481
|
+
reason: `${ADVANCED_SEARCH_ENDPOINT} demonstrated with the applied query`,
|
|
1482
|
+
};
|
|
1483
|
+
} catch (error: any) {
|
|
1484
|
+
const status =
|
|
1485
|
+
typeof error?.status === 'number' ? error.status : undefined;
|
|
1486
|
+
// Surface the absence/log it; never swallow it silently.
|
|
1487
|
+
this.logger.warn('Optional advanced search endpoint not demonstrated', {
|
|
1488
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1489
|
+
status,
|
|
1490
|
+
message: error?.message,
|
|
1491
|
+
});
|
|
1492
|
+
return {
|
|
1493
|
+
available: false,
|
|
1494
|
+
applied: new Set(),
|
|
1495
|
+
reason:
|
|
1496
|
+
status !== undefined
|
|
1497
|
+
? `${ADVANCED_SEARCH_ENDPOINT} returned ${status}; optional advanced endpoint not demonstrated`
|
|
1498
|
+
: `${ADVANCED_SEARCH_ENDPOINT} unavailable: ${extractErrorMessage(error)}`,
|
|
1499
|
+
...(status !== undefined ? { status } : {}),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Build the advanced-endpoint query parameters from the canonical query. The
|
|
1506
|
+
* SDK serializes the plain object, so no manual stringification is needed.
|
|
1507
|
+
*/
|
|
1508
|
+
private buildAdvancedSearchQuery(
|
|
1509
|
+
query: CatalogQuery,
|
|
1510
|
+
categoryIds: string[],
|
|
1511
|
+
collectionIds: string[],
|
|
1512
|
+
): Record<string, unknown> {
|
|
1513
|
+
const advancedQuery: Record<string, unknown> = {};
|
|
1514
|
+
if (query.text) advancedQuery.q = query.text;
|
|
1515
|
+
if (categoryIds.length > 0) advancedQuery.categories = categoryIds.join(',');
|
|
1516
|
+
if (collectionIds.length > 0) {
|
|
1517
|
+
advancedQuery.collections = collectionIds.join(',');
|
|
1518
|
+
}
|
|
1519
|
+
if (query.priceMin !== undefined) advancedQuery.price_min = query.priceMin;
|
|
1520
|
+
if (query.priceMax !== undefined) advancedQuery.price_max = query.priceMax;
|
|
1521
|
+
if (query.inStock === true) advancedQuery.isAvailable = true;
|
|
1522
|
+
if (query.attributes) {
|
|
1523
|
+
// The backend route declares `attributes: z.string()` and JSON-parses an
|
|
1524
|
+
// ARRAY of `{ id, values }`. The canonical query carries a
|
|
1525
|
+
// `Record<string, string[]>`, so map it before serializing.
|
|
1526
|
+
const attributes = Object.entries(query.attributes).map(([id, values]) => ({
|
|
1527
|
+
id,
|
|
1528
|
+
values,
|
|
1529
|
+
}));
|
|
1530
|
+
advancedQuery.attributes = JSON.stringify(attributes);
|
|
1531
|
+
}
|
|
1532
|
+
if (query.sort) {
|
|
1533
|
+
const mappedOrder = MEDUSA_SORT_KEY_MAP[query.sort] ?? null;
|
|
1534
|
+
if (mappedOrder !== null) advancedQuery.order = mappedOrder;
|
|
1535
|
+
}
|
|
1536
|
+
if (this.regionId) advancedQuery.region_id = this.regionId;
|
|
1537
|
+
if (this.currencyCode) {
|
|
1538
|
+
advancedQuery.currency_code = this.currencyCode.toLowerCase();
|
|
1539
|
+
}
|
|
1540
|
+
return advancedQuery;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Resolve the effective category scope of a query to backend IDs. */
|
|
1544
|
+
private async resolveScopeCategoryIds(query: CatalogQuery): Promise<string[]> {
|
|
1545
|
+
const handles = resolveEffectiveCategoryHandles(query);
|
|
1546
|
+
if (handles === undefined || handles.length === 0) return [];
|
|
1547
|
+
return this.resolveCategoryIds(handles);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Advertised filterable attribute handles for the scope. The custom-attributes
|
|
1552
|
+
* endpoint is facet *metadata* (which attributes exist), not proof that a
|
|
1553
|
+
* filter can be applied; these handles are candidates only, and each still
|
|
1554
|
+
* requires the advanced endpoint to echo it as applied before it is offered.
|
|
1555
|
+
*/
|
|
1556
|
+
private async resolveFilterableAttributeHandles(
|
|
1557
|
+
categoryIds: string[],
|
|
1558
|
+
): Promise<string[]> {
|
|
1559
|
+
const handles: string[] = [];
|
|
1560
|
+
for (const categoryId of categoryIds) {
|
|
1561
|
+
try {
|
|
1562
|
+
// Custom route: use the SDK client so the publishable API key and
|
|
1563
|
+
// headers are applied automatically (building-storefronts
|
|
1564
|
+
// `sdk-client-fetch`).
|
|
1565
|
+
const data: any = await this.sdk.client.fetch(
|
|
1566
|
+
`/store/custom-attributes/category/${categoryId}`,
|
|
1567
|
+
);
|
|
1568
|
+
for (const item of data?.attributes ?? []) {
|
|
1569
|
+
const attr = item.attribute ?? item;
|
|
1570
|
+
if (
|
|
1571
|
+
attr?.is_filterable &&
|
|
1572
|
+
attr?.is_active &&
|
|
1573
|
+
typeof attr.handle === 'string' &&
|
|
1574
|
+
attr.handle.length > 0
|
|
1575
|
+
) {
|
|
1576
|
+
handles.push(attr.handle);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
} catch {
|
|
1580
|
+
// The optional custom-attributes endpoint may be absent; its absence
|
|
1581
|
+
// simply means no advertised attribute facets for the scope.
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return handles;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** Build a typed `unsupported-query` outcome naming the offending fields. */
|
|
1588
|
+
private unsupportedCatalogOutcome(fields: string[]): CatalogQueryOutcome {
|
|
1589
|
+
return {
|
|
1590
|
+
ok: false,
|
|
1591
|
+
error: {
|
|
1592
|
+
code: 'unsupported-query',
|
|
1593
|
+
fields,
|
|
1594
|
+
message: `Unsupported catalog query: ${fields.join(', ')}`,
|
|
1595
|
+
repair: { remove: fields },
|
|
1596
|
+
},
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
|
|
527
1600
|
async getProductRecommendations(productId: string): Promise<Product[]> {
|
|
528
1601
|
this.logger.debug('getProductRecommendations', { productId });
|
|
529
1602
|
|
|
@@ -555,18 +1628,14 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
555
1628
|
}> {
|
|
556
1629
|
this.logger.debug('searchProductsAdvanced', options);
|
|
557
1630
|
|
|
558
|
-
// Check if advanced search is available
|
|
1631
|
+
// Check if advanced search is available. A capability boolean is not proof,
|
|
1632
|
+
// but when it is false the optional endpoint is definitely not wired; a
|
|
1633
|
+
// basic-only caller still gets the faithful `getProducts` fallback while a
|
|
1634
|
+
// caller that requested an advanced filter is rejected instead of silently
|
|
1635
|
+
// receiving unfiltered products (E2 task 2.4).
|
|
559
1636
|
if (!this.capabilities.advancedSearch) {
|
|
560
|
-
this.logger.warn('Advanced search
|
|
561
|
-
|
|
562
|
-
return {
|
|
563
|
-
products,
|
|
564
|
-
meta: {
|
|
565
|
-
limit: options?.first ?? 20,
|
|
566
|
-
offset: Number(options?.after ?? 0),
|
|
567
|
-
hybridSearchUsed: false,
|
|
568
|
-
},
|
|
569
|
-
};
|
|
1637
|
+
this.logger.warn('Advanced search capability disabled, using faithful fallback');
|
|
1638
|
+
return this.fallbackAdvancedSearch(options);
|
|
570
1639
|
}
|
|
571
1640
|
|
|
572
1641
|
try {
|
|
@@ -704,22 +1773,49 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
704
1773
|
|
|
705
1774
|
return { products, meta };
|
|
706
1775
|
} catch (error: any) {
|
|
707
|
-
this.logger.error('Advanced search failed,
|
|
1776
|
+
this.logger.error('Advanced search failed, using faithful fallback', {
|
|
708
1777
|
error: error.message,
|
|
709
1778
|
stack: error.stack,
|
|
710
1779
|
});
|
|
711
1780
|
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1781
|
+
// Do not silently drop advanced filters: a caller that requested price,
|
|
1782
|
+
// stock, or attributes gets a typed failure; a basic-only caller keeps the
|
|
1783
|
+
// faithful `getProducts` fallback.
|
|
1784
|
+
return this.fallbackAdvancedSearch(options);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Faithful fallback for `searchProductsAdvanced`. A basic-only request is
|
|
1790
|
+
* served by `getProducts`; a request that depends on the optional advanced
|
|
1791
|
+
* endpoint is rejected as a typed `ProviderAPIError` naming the unappliable
|
|
1792
|
+
* fields, so a failed advanced search never returns an unfiltered result
|
|
1793
|
+
* labelled success (E2 task 2.4).
|
|
1794
|
+
*/
|
|
1795
|
+
private async fallbackAdvancedSearch(
|
|
1796
|
+
options?: AdvancedSearchProductsOptions,
|
|
1797
|
+
): Promise<{
|
|
1798
|
+
products: Product[];
|
|
1799
|
+
meta?: SearchResultMeta;
|
|
1800
|
+
}> {
|
|
1801
|
+
const requested = collectAdvancedSearchRequestFields(options);
|
|
1802
|
+
if (requested.length > 0) {
|
|
1803
|
+
throw new ProviderAPIError(
|
|
1804
|
+
`Advanced search unavailable for requested fields: ${requested.join(', ')}`,
|
|
1805
|
+
'medusa',
|
|
1806
|
+
undefined,
|
|
1807
|
+
{ fields: requested, repair: { remove: requested } },
|
|
1808
|
+
);
|
|
722
1809
|
}
|
|
1810
|
+
const products = await this.getProducts(options);
|
|
1811
|
+
return {
|
|
1812
|
+
products,
|
|
1813
|
+
meta: {
|
|
1814
|
+
limit: options?.first ?? 20,
|
|
1815
|
+
offset: Number(options?.after ?? 0),
|
|
1816
|
+
hybridSearchUsed: false,
|
|
1817
|
+
},
|
|
1818
|
+
};
|
|
723
1819
|
}
|
|
724
1820
|
|
|
725
1821
|
/**
|
|
@@ -780,29 +1876,38 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
780
1876
|
}
|
|
781
1877
|
}
|
|
782
1878
|
|
|
783
|
-
// 2. Advanced filters (
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
isActive: false,
|
|
804
|
-
ranking: 5,
|
|
1879
|
+
// 2. Advanced filters (stock)
|
|
1880
|
+
//
|
|
1881
|
+
// A price range is emitted ONLY when the backend supplies real range
|
|
1882
|
+
// metadata for the current query. Medusa's Store `/store/products`
|
|
1883
|
+
// returns the matching products and a total count, not the filterable
|
|
1884
|
+
// values or their numeric ranges (Store API contract; facets require a
|
|
1885
|
+
// separate search plugin), so there is no price range to read here and
|
|
1886
|
+
// the control is omitted instead of defaulted to a fabricated
|
|
1887
|
+
// `0..10000` (E2 Decision 4; spec "Facet counts and ranges are never
|
|
1888
|
+
// fabricated").
|
|
1889
|
+
//
|
|
1890
|
+
// The stock toggle is offered ONLY when the real optional endpoint has
|
|
1891
|
+
// demonstrated `inStock` for the scope. A capability boolean is not proof
|
|
1892
|
+
// (E2 task 2.4): the backend advertises `advancedSearch: true` while the
|
|
1893
|
+
// optional `/store/search` endpoint is absent, so the toggle is held
|
|
1894
|
+
// rather than shown.
|
|
1895
|
+
if (options.useAdvancedSearch) {
|
|
1896
|
+
const support = await this.getCatalogFacetSupport({
|
|
1897
|
+
categoryIds: options.categoryId ? [options.categoryId] : [],
|
|
1898
|
+
scope: options.categoryId,
|
|
805
1899
|
});
|
|
1900
|
+
if (support.offered.includes('inStock')) {
|
|
1901
|
+
// Stock availability filter
|
|
1902
|
+
// ID must match the URL param read by page.tsx (?isAvailable=true)
|
|
1903
|
+
filters.push({
|
|
1904
|
+
id: 'isAvailable',
|
|
1905
|
+
label: 'In Stock',
|
|
1906
|
+
type: 'boolean',
|
|
1907
|
+
isActive: false,
|
|
1908
|
+
ranking: 5,
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
806
1911
|
}
|
|
807
1912
|
|
|
808
1913
|
// 3. Custom attributes (category-specific)
|
|
@@ -852,20 +1957,37 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
852
1957
|
ranking: attr.ranking || 50, // Custom attributes lower priority
|
|
853
1958
|
};
|
|
854
1959
|
|
|
855
|
-
// Add options for single/multi types
|
|
1960
|
+
// Add options for single/multi types. A per-option count is
|
|
1961
|
+
// attached only when the caller asked for counts AND the backend
|
|
1962
|
+
// supplied a real finite number; no zero or fabricated count is
|
|
1963
|
+
// invented (E2 Decision 4; spec "Facet counts and ranges are
|
|
1964
|
+
// never fabricated").
|
|
856
1965
|
if ((filterType === 'single' || filterType === 'multi') && attr.values) {
|
|
857
|
-
filter.options = attr.values.map((val: any) =>
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1966
|
+
filter.options = attr.values.map((val: any) => {
|
|
1967
|
+
const count = options.includeFacetCounts
|
|
1968
|
+
? readFiniteFacetNumber(val.count)
|
|
1969
|
+
: undefined;
|
|
1970
|
+
return {
|
|
1971
|
+
value: this.toSlug(val.value), // URL slug: ?ram=8gb
|
|
1972
|
+
label: val.value, // Display: "8GB"
|
|
1973
|
+
id: val.id, // Backend ID: 01KN03N67V13K5S2E6B6695KRN
|
|
1974
|
+
...(count !== undefined ? { count } : {}),
|
|
1975
|
+
};
|
|
1976
|
+
});
|
|
863
1977
|
}
|
|
864
1978
|
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1979
|
+
// A numeric range is emitted only when the backend supplies BOTH
|
|
1980
|
+
// real bounds. A missing bound is never replaced by a fabricated
|
|
1981
|
+
// `0`/`100` default; without a real range the control is omitted
|
|
1982
|
+
// rather than shown with invented values (E2 Decision 4).
|
|
1983
|
+
if (filterType === 'range') {
|
|
1984
|
+
const min = readFiniteFacetNumber(attr.metadata?.min);
|
|
1985
|
+
const max = readFiniteFacetNumber(attr.metadata?.max);
|
|
1986
|
+
if (min === undefined || max === undefined) {
|
|
1987
|
+
return; // no real range → omit this range filter
|
|
1988
|
+
}
|
|
1989
|
+
filter.min = min;
|
|
1990
|
+
filter.max = max;
|
|
869
1991
|
}
|
|
870
1992
|
|
|
871
1993
|
filters.push(filter);
|
|
@@ -1311,9 +2433,9 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1311
2433
|
}
|
|
1312
2434
|
|
|
1313
2435
|
try {
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
const limit = options.
|
|
2436
|
+
// Bounded per-request limit (change B, decision 5): no unbounded scan.
|
|
2437
|
+
// Client-side search filtering still runs over the bounded page.
|
|
2438
|
+
const limit = options.limit ?? this.catalogPageSize;
|
|
1317
2439
|
|
|
1318
2440
|
const queryParams: any = {
|
|
1319
2441
|
limit,
|
|
@@ -1376,13 +2498,24 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1376
2498
|
// Then get products for this category
|
|
1377
2499
|
const categoryQueryParams: any = {
|
|
1378
2500
|
category_id: [category.id],
|
|
1379
|
-
|
|
1380
|
-
limit: options.first ??
|
|
2501
|
+
// Bounded, paginated listing (change B, decision 5).
|
|
2502
|
+
limit: options.first ?? this.catalogPageSize,
|
|
1381
2503
|
offset: options.after ? parseInt(options.after, 10) : undefined,
|
|
1382
2504
|
fields:
|
|
1383
|
-
'+variants,+variants.prices,+variants.
|
|
2505
|
+
'+variants,+variants.prices,+variants.calculated_price,+variants.inventory_quantity,+options,+images,+tags',
|
|
1384
2506
|
};
|
|
1385
2507
|
|
|
2508
|
+
// Translate the Thor sort key through the same mapping `getProducts` uses.
|
|
2509
|
+
// A raw canonical key (`name`/`newest`/`featured`) is not a valid Medusa
|
|
2510
|
+
// `order` field and would be rejected by the Store API; `null` omits
|
|
2511
|
+
// `order` so the backend returns its default ordering.
|
|
2512
|
+
const mappedOrder = options.sortKey
|
|
2513
|
+
? MEDUSA_SORT_KEY_MAP[options.sortKey] ?? null
|
|
2514
|
+
: null;
|
|
2515
|
+
if (mappedOrder !== null) {
|
|
2516
|
+
categoryQueryParams.order = mappedOrder;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
1386
2519
|
// Region-based pricing (calculated_price requires region_id in Medusa v2)
|
|
1387
2520
|
if (this.regionId) {
|
|
1388
2521
|
categoryQueryParams.region_id = this.regionId;
|
|
@@ -1436,7 +2569,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1436
2569
|
// passing it explicitly is required when the key maps to multiple
|
|
1437
2570
|
// channels (otherwise cart creation errors).
|
|
1438
2571
|
const salesChannelId =
|
|
1439
|
-
options?.salesChannelId ??
|
|
2572
|
+
options?.salesChannelId ?? resolveConfiguredSalesChannelId();
|
|
1440
2573
|
if (salesChannelId) {
|
|
1441
2574
|
createPayload.sales_channel_id = salesChannelId;
|
|
1442
2575
|
}
|
|
@@ -1529,7 +2662,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1529
2662
|
};
|
|
1530
2663
|
|
|
1531
2664
|
// Scope to storefront sales channel when configured.
|
|
1532
|
-
const salesChannelId =
|
|
2665
|
+
const salesChannelId = resolveConfiguredSalesChannelId();
|
|
1533
2666
|
if (salesChannelId) {
|
|
1534
2667
|
variantCheckParams.sales_channel_id = [salesChannelId];
|
|
1535
2668
|
}
|
|
@@ -1760,13 +2893,17 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1760
2893
|
// ============================================
|
|
1761
2894
|
|
|
1762
2895
|
/**
|
|
1763
|
-
* Get all orders for a customer (storefront).
|
|
2896
|
+
* Get all orders for a customer (storefront) as an array (legacy surface).
|
|
1764
2897
|
*
|
|
1765
|
-
*
|
|
1766
|
-
*
|
|
2898
|
+
* Delegates to the channel-scoped paginated contract
|
|
2899
|
+
* ({@link customerOrdersPage}) and returns that window's orders. The backend
|
|
2900
|
+
* (`GET /store/thor/orders`) scopes by BOTH the authenticated customer and
|
|
2901
|
+
* the sales channel resolved from the publishable key, so cross-channel
|
|
2902
|
+
* orders are excluded by the backend before pagination and **no client-side
|
|
2903
|
+
* channel filter runs**.
|
|
1767
2904
|
*
|
|
1768
|
-
* @param options - Order query options (supports
|
|
1769
|
-
* @returns Array of orders for the authenticated customer
|
|
2905
|
+
* @param options - Order query options (supports a custom fetcher)
|
|
2906
|
+
* @returns Array of orders for the authenticated customer on the store channel
|
|
1770
2907
|
*
|
|
1771
2908
|
* @example
|
|
1772
2909
|
* ```typescript
|
|
@@ -1783,40 +2920,87 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1783
2920
|
return customFetcher(options);
|
|
1784
2921
|
}
|
|
1785
2922
|
|
|
1786
|
-
|
|
2923
|
+
const page = await this.customerOrdersPage({
|
|
2924
|
+
page: 1,
|
|
2925
|
+
pageSize: options.limit ?? CUSTOMER_ORDERS_MAX_PAGE_SIZE,
|
|
2926
|
+
salesChannelId,
|
|
2927
|
+
});
|
|
2928
|
+
return page.orders;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
/**
|
|
2932
|
+
* Channel-scoped, paginated order history for the authenticated customer.
|
|
2933
|
+
*
|
|
2934
|
+
* Calls the backend route `GET /store/thor/orders`, which requires the
|
|
2935
|
+
* customer session and scopes `order` by `customer_id` **and** the
|
|
2936
|
+
* `sales_channel_id` resolved from the publishable key, returning
|
|
2937
|
+
* `{ orders, count, limit, offset }` with the backend's real total computed
|
|
2938
|
+
* before pagination.
|
|
2939
|
+
*
|
|
2940
|
+
* The `salesChannelId` option is the store's explicitly configured channel.
|
|
2941
|
+
* When supplied, every returned order is asserted to belong to it; a
|
|
2942
|
+
* foreign-channel order (for example from a multi-channel publishable key that
|
|
2943
|
+
* silently resolved an arbitrary first id) fails closed with a typed error
|
|
2944
|
+
* rather than being presented as the customer's history.
|
|
2945
|
+
*
|
|
2946
|
+
* @param options - Bounded pagination window and the store's sales channel
|
|
2947
|
+
* @returns The backend's `{ orders, count, limit, offset }`
|
|
2948
|
+
*/
|
|
2949
|
+
async customerOrdersPage(
|
|
2950
|
+
options: GetCustomerOrdersPageOptions = {},
|
|
2951
|
+
): Promise<CustomerOrdersPage> {
|
|
2952
|
+
const { page, pageSize, limit, offset } = normalizeCustomerOrdersPage(options);
|
|
2953
|
+
const { salesChannelId } = options;
|
|
2954
|
+
|
|
2955
|
+
this.logger.info('Getting channel-scoped customer orders', { page, pageSize });
|
|
1787
2956
|
|
|
1788
2957
|
try {
|
|
1789
|
-
const
|
|
2958
|
+
const response = await this.withRetry(
|
|
1790
2959
|
async () =>
|
|
1791
|
-
this.sdk.
|
|
1792
|
-
|
|
1793
|
-
|
|
2960
|
+
this.sdk.client.fetch<{
|
|
2961
|
+
orders?: any[];
|
|
2962
|
+
count?: number;
|
|
2963
|
+
limit?: number;
|
|
2964
|
+
offset?: number;
|
|
2965
|
+
}>('/store/thor/orders', {
|
|
2966
|
+
method: 'GET',
|
|
2967
|
+
query: { limit, offset },
|
|
1794
2968
|
}),
|
|
1795
|
-
'
|
|
2969
|
+
'customerOrdersPage',
|
|
1796
2970
|
{},
|
|
1797
2971
|
);
|
|
1798
2972
|
|
|
1799
|
-
const
|
|
1800
|
-
this.logger.debug(`Found ${
|
|
1801
|
-
|
|
1802
|
-
const transformed: Order[] = orders.map((order: any) => transformOrder(order));
|
|
2973
|
+
const rawOrders = response.orders ?? [];
|
|
2974
|
+
this.logger.debug(`Found ${rawOrders.length} channel-scoped orders for customer`);
|
|
1803
2975
|
|
|
1804
|
-
//
|
|
1805
|
-
|
|
1806
|
-
if (
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
2976
|
+
// Fail closed if the backend scoped to a channel other than the store's
|
|
2977
|
+
// configured one (a multi-channel key must never be silently accepted).
|
|
2978
|
+
if (salesChannelId) {
|
|
2979
|
+
const foreign = rawOrders.find(
|
|
2980
|
+
(order: any) =>
|
|
2981
|
+
typeof order?.sales_channel_id === 'string' &&
|
|
2982
|
+
order.sales_channel_id !== salesChannelId,
|
|
1810
2983
|
);
|
|
1811
|
-
|
|
2984
|
+
if (foreign) {
|
|
2985
|
+
throw new ProviderAPIError(
|
|
2986
|
+
`Channel-scoped order history returned an order outside the store's sales channel (${salesChannelId}).`,
|
|
2987
|
+
'medusa',
|
|
2988
|
+
);
|
|
2989
|
+
}
|
|
1812
2990
|
}
|
|
1813
2991
|
|
|
1814
|
-
return
|
|
2992
|
+
return {
|
|
2993
|
+
orders: rawOrders.map((order: any) => transformOrder(order)),
|
|
2994
|
+
count: typeof response.count === 'number' ? response.count : rawOrders.length,
|
|
2995
|
+
limit: typeof response.limit === 'number' ? response.limit : limit,
|
|
2996
|
+
offset: typeof response.offset === 'number' ? response.offset : offset,
|
|
2997
|
+
};
|
|
1815
2998
|
} catch (error) {
|
|
2999
|
+
if (error instanceof ProviderAPIError) throw error;
|
|
1816
3000
|
const status = (error as any)?.status || (error as any)?.statusCode;
|
|
1817
3001
|
const errorMessage = extractErrorMessage(error);
|
|
1818
3002
|
|
|
1819
|
-
this.logger.error('Error getting orders', { error: errorMessage });
|
|
3003
|
+
this.logger.error('Error getting channel-scoped orders', { error: errorMessage });
|
|
1820
3004
|
throw new ProviderAPIError(
|
|
1821
3005
|
`Failed to fetch orders: ${errorMessage}`,
|
|
1822
3006
|
'medusa',
|
|
@@ -2925,36 +4109,96 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
2925
4109
|
* @throws {ProviderAPIError} If checkout fails
|
|
2926
4110
|
*/
|
|
2927
4111
|
async completeCheckout(cartId: string): Promise<Order> {
|
|
2928
|
-
this.
|
|
2929
|
-
|
|
2930
|
-
try {
|
|
2931
|
-
return await this.withRetry(
|
|
2932
|
-
async () => {
|
|
2933
|
-
// Medusa v2: POST /store/carts/:id/complete
|
|
2934
|
-
const { order } = (await this.sdk.store.cart.complete(cartId)) as any;
|
|
2935
|
-
|
|
2936
|
-
if (!order) {
|
|
2937
|
-
throw new ProviderAPIError('Checkout completed but no order returned', 'medusa', 500);
|
|
2938
|
-
}
|
|
4112
|
+
const result = await this.completeCart(cartId);
|
|
2939
4113
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
});
|
|
4114
|
+
if (result.type === 'order') {
|
|
4115
|
+
return result.order;
|
|
4116
|
+
}
|
|
2944
4117
|
|
|
2945
|
-
|
|
2946
|
-
},
|
|
2947
|
-
'completeCheckout',
|
|
2948
|
-
{ cartId },
|
|
2949
|
-
);
|
|
2950
|
-
} catch (error: any) {
|
|
2951
|
-
this.logger.error('Failed to complete checkout', { cartId, error: error.message });
|
|
4118
|
+
if (result.type === 'cart') {
|
|
2952
4119
|
throw new ProviderAPIError(
|
|
2953
|
-
`Failed to complete checkout: ${error.message}`,
|
|
4120
|
+
`Failed to complete checkout: ${result.error.message}`,
|
|
2954
4121
|
'medusa',
|
|
2955
|
-
error.status,
|
|
4122
|
+
result.error.status,
|
|
2956
4123
|
);
|
|
2957
4124
|
}
|
|
4125
|
+
|
|
4126
|
+
throw new ProviderAPIError(
|
|
4127
|
+
`Checkout outcome is unresolved: ${result.error.message}`,
|
|
4128
|
+
'medusa',
|
|
4129
|
+
result.error.status,
|
|
4130
|
+
);
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
/**
|
|
4134
|
+
* Complete a cart exactly once and preserve the native discriminator
|
|
4135
|
+
* (change B, decision 11).
|
|
4136
|
+
*
|
|
4137
|
+
* Medusa's `POST /store/carts/:id/complete` returns HTTP 200 whose body is
|
|
4138
|
+
* either `{ type: 'order', order }` or `{ type: 'cart', cart, error }`. This
|
|
4139
|
+
* method never retries: a thrown transport/timeout/5xx failure becomes an
|
|
4140
|
+
* `unresolved` result so the caller reconciles the committed cart before any
|
|
4141
|
+
* further attempt. The Medusa JS SDK performs no implicit retry of its own,
|
|
4142
|
+
* so there is no blind retry beneath this call either.
|
|
4143
|
+
*/
|
|
4144
|
+
async completeCart(cartId: string): Promise<CartCompletionResult> {
|
|
4145
|
+
this.logger.debug('completeCart', { cartId });
|
|
4146
|
+
|
|
4147
|
+
try {
|
|
4148
|
+
const response = (await this.sdk.store.cart.complete(cartId, {
|
|
4149
|
+
fields: MEDUSA_ORDER_COMPLETION_FIELDS,
|
|
4150
|
+
} as any)) as any;
|
|
4151
|
+
|
|
4152
|
+
if (response?.type === 'cart' || (response?.cart && response?.error)) {
|
|
4153
|
+
const error = this.toCompletionError(response.error);
|
|
4154
|
+
this.logger.warn('Cart completion returned a cart with an error', {
|
|
4155
|
+
cartId,
|
|
4156
|
+
code: error.code,
|
|
4157
|
+
message: error.message,
|
|
4158
|
+
});
|
|
4159
|
+
return { type: 'cart', cart: transformCart(response.cart), error };
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
const order = response?.order;
|
|
4163
|
+
if (!order) {
|
|
4164
|
+
return {
|
|
4165
|
+
type: 'unresolved',
|
|
4166
|
+
error: { message: 'Checkout returned neither an order nor a cart error' },
|
|
4167
|
+
};
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
this.logger.info('Checkout completed successfully', {
|
|
4171
|
+
orderId: order.id,
|
|
4172
|
+
displayId: order.display_id,
|
|
4173
|
+
});
|
|
4174
|
+
return { type: 'order', order: transformOrder(order) };
|
|
4175
|
+
} catch (error: unknown) {
|
|
4176
|
+
const completionError = this.toCompletionError(error);
|
|
4177
|
+
this.logger.error('Cart completion failed (unresolved, not retried)', {
|
|
4178
|
+
cartId,
|
|
4179
|
+
status: completionError.status,
|
|
4180
|
+
message: completionError.message,
|
|
4181
|
+
});
|
|
4182
|
+
return { type: 'unresolved', error: completionError };
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
/** Normalize any thrown/returned completion failure into a structured error. */
|
|
4187
|
+
private toCompletionError(error: unknown): CartCompletionError {
|
|
4188
|
+
if (error && typeof error === 'object') {
|
|
4189
|
+
const err = error as any;
|
|
4190
|
+
const message =
|
|
4191
|
+
typeof err.message === 'string' && err.message.length > 0
|
|
4192
|
+
? err.message
|
|
4193
|
+
: extractErrorMessage(error);
|
|
4194
|
+
return {
|
|
4195
|
+
message,
|
|
4196
|
+
code: typeof err.code === 'string' ? err.code : undefined,
|
|
4197
|
+
status: err.status ?? err.statusCode,
|
|
4198
|
+
raw: err,
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
return { message: extractErrorMessage(error), raw: error };
|
|
2958
4202
|
}
|
|
2959
4203
|
|
|
2960
4204
|
/**
|
|
@@ -2967,160 +4211,90 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
2967
4211
|
* @throws {ProviderAPIError} If cart not found or region not configured
|
|
2968
4212
|
*/
|
|
2969
4213
|
async getShippingMethods(cartId: string): Promise<import('@thorprovider/types').ShippingMethod[]> {
|
|
2970
|
-
this.logger.
|
|
4214
|
+
this.logger.debug('getShippingMethods', { cartId });
|
|
2971
4215
|
|
|
2972
4216
|
try {
|
|
2973
4217
|
return await this.withRetry(
|
|
2974
4218
|
async () => {
|
|
2975
|
-
//
|
|
2976
|
-
this.logger.info('🚚 [DEBUG] Step 1: Retrieving cart from Medusa...', { cartId });
|
|
4219
|
+
// The cart supplies the region and currency the options are priced in.
|
|
2977
4220
|
const cartResponse = (await this.sdk.store.cart.retrieve(cartId)) as any;
|
|
2978
4221
|
const { cart } = cartResponse;
|
|
2979
4222
|
|
|
2980
|
-
// Log raw cart response for debugging
|
|
2981
|
-
this.logger.info('🚚 [DEBUG] Step 2: Cart retrieved from backend', {
|
|
2982
|
-
cartId,
|
|
2983
|
-
hasCart: !!cart,
|
|
2984
|
-
cartKeys: cart ? Object.keys(cart) : [],
|
|
2985
|
-
regionId: cart?.region_id,
|
|
2986
|
-
currencyCode: cart?.currency_code,
|
|
2987
|
-
shippingAddress: cart?.shipping_address
|
|
2988
|
-
? {
|
|
2989
|
-
country: cart.shipping_address.country_code,
|
|
2990
|
-
postal: cart.shipping_address.postal_code,
|
|
2991
|
-
}
|
|
2992
|
-
: null,
|
|
2993
|
-
});
|
|
2994
|
-
|
|
2995
4223
|
if (!cart) {
|
|
2996
|
-
this.logger.error('🚚 [DEBUG] ERROR: Cart not found in response', {
|
|
2997
|
-
cartId,
|
|
2998
|
-
cartResponse,
|
|
2999
|
-
});
|
|
3000
4224
|
throw new ResourceNotFoundError('Cart', cartId);
|
|
3001
4225
|
}
|
|
3002
4226
|
|
|
3003
4227
|
if (!cart.region_id) {
|
|
3004
|
-
this.logger.error('🚚 [DEBUG] ERROR: Cart has no region_id', {
|
|
3005
|
-
cartId,
|
|
3006
|
-
cart: {
|
|
3007
|
-
id: cart.id,
|
|
3008
|
-
region_id: cart.region_id,
|
|
3009
|
-
region: cart.region,
|
|
3010
|
-
allKeys: Object.keys(cart),
|
|
3011
|
-
},
|
|
3012
|
-
});
|
|
3013
4228
|
throw new ProviderAPIError('Cart has no region assigned', 'medusa', 400);
|
|
3014
4229
|
}
|
|
3015
4230
|
|
|
3016
|
-
//
|
|
3017
|
-
//
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
const
|
|
3030
|
-
|
|
3031
|
-
// Prefer SDK if present (newer SDK versions)
|
|
3032
|
-
if (storeAny?.shippingOption?.list) {
|
|
3033
|
-
shippingResponse = (await storeAny.shippingOption.list({
|
|
3034
|
-
cart_id: cartId,
|
|
3035
|
-
})) as any;
|
|
3036
|
-
} else {
|
|
3037
|
-
// Fallback for SDK versions that don't expose shippingOption typings/APIs
|
|
3038
|
-
const baseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
|
|
3039
|
-
const url = new URL('store/shipping-options', baseUrl);
|
|
3040
|
-
url.searchParams.set('cart_id', cartId);
|
|
3041
|
-
|
|
3042
|
-
const response = await fetchWithRetry(
|
|
3043
|
-
url.toString(),
|
|
3044
|
-
{
|
|
3045
|
-
method: 'GET',
|
|
3046
|
-
headers: {
|
|
3047
|
-
'x-publishable-api-key': this.publishableKey,
|
|
3048
|
-
'content-type': 'application/json',
|
|
3049
|
-
},
|
|
3050
|
-
},
|
|
3051
|
-
this.retryConfig,
|
|
3052
|
-
);
|
|
3053
|
-
|
|
3054
|
-
shippingResponse = (await response.json()) as any;
|
|
3055
|
-
}
|
|
4231
|
+
// Medusa v2: list the shipping options eligible for this cart. The
|
|
4232
|
+
// JS SDK exposes `store.fulfillment.listCartOptions`; fall back to the
|
|
4233
|
+
// equivalent HTTP route for SDK builds that do not.
|
|
4234
|
+
let shippingOptions: any[] = [];
|
|
4235
|
+
const storeAny = this.sdk.store as any;
|
|
4236
|
+
|
|
4237
|
+
if (storeAny?.fulfillment?.listCartOptions) {
|
|
4238
|
+
const response = (await storeAny.fulfillment.listCartOptions({
|
|
4239
|
+
cart_id: cartId,
|
|
4240
|
+
})) as any;
|
|
4241
|
+
shippingOptions = response?.shipping_options ?? [];
|
|
4242
|
+
} else {
|
|
4243
|
+
const baseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
|
|
4244
|
+
const url = new URL('store/shipping-options', baseUrl);
|
|
4245
|
+
url.searchParams.set('cart_id', cartId);
|
|
3056
4246
|
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
// Log detailed error from Medusa backend
|
|
3060
|
-
this.logger.error(
|
|
3061
|
-
'🚚 [DEBUG] ERROR: Medusa SDK threw error when fetching shipping options',
|
|
4247
|
+
const response = await fetchWithRetry(
|
|
4248
|
+
url.toString(),
|
|
3062
4249
|
{
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
errorCode: sdkError?.code,
|
|
3069
|
-
errorResponse: sdkError?.response
|
|
3070
|
-
? JSON.stringify(sdkError.response, null, 2)
|
|
3071
|
-
: 'N/A',
|
|
3072
|
-
errorBody: sdkError?.body ? JSON.stringify(sdkError.body, null, 2) : 'N/A',
|
|
3073
|
-
fullError: JSON.stringify(sdkError, Object.getOwnPropertyNames(sdkError), 2),
|
|
4250
|
+
method: 'GET',
|
|
4251
|
+
headers: {
|
|
4252
|
+
'x-publishable-api-key': this.publishableKey,
|
|
4253
|
+
'content-type': 'application/json',
|
|
4254
|
+
},
|
|
3074
4255
|
},
|
|
4256
|
+
this.retryConfig,
|
|
3075
4257
|
);
|
|
3076
|
-
|
|
4258
|
+
|
|
4259
|
+
shippingOptions = ((await response.json()) as any)?.shipping_options ?? [];
|
|
3077
4260
|
}
|
|
3078
4261
|
|
|
3079
|
-
|
|
3080
|
-
this.logger.info('🚚 [DEBUG] Step 4: Shipping options received from backend', {
|
|
3081
|
-
cartId,
|
|
3082
|
-
totalOptions: shipping_options?.length || 0,
|
|
3083
|
-
rawResponse: JSON.stringify(shippingResponse, null, 2),
|
|
3084
|
-
firstOption: shipping_options?.[0]
|
|
3085
|
-
? {
|
|
3086
|
-
id: shipping_options[0].id,
|
|
3087
|
-
name: shipping_options[0].name,
|
|
3088
|
-
allKeys: Object.keys(shipping_options[0]),
|
|
3089
|
-
price_incl_tax: shipping_options[0].price_incl_tax,
|
|
3090
|
-
amount: shipping_options[0].amount,
|
|
3091
|
-
data: shipping_options[0].data,
|
|
3092
|
-
}
|
|
3093
|
-
: null,
|
|
3094
|
-
});
|
|
4262
|
+
const currencyCode = cart.currency_code?.toUpperCase() ?? '';
|
|
3095
4263
|
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
4264
|
+
// Truthful projection (change B, decision 17): the amount is the
|
|
4265
|
+
// backend's option amount (or its tax-inclusive amount) and is never
|
|
4266
|
+
// replaced with a fabricated `'0'`; an absent description/ETA is
|
|
4267
|
+
// omitted rather than filled with a placeholder like `'N/A'`.
|
|
4268
|
+
return shippingOptions.map((option: any) => {
|
|
4269
|
+
const amount = option?.amount ?? option?.price_incl_tax;
|
|
4270
|
+
const estimatedDays =
|
|
4271
|
+
option?.data?.estimated_days != null
|
|
4272
|
+
? String(option.data.estimated_days)
|
|
4273
|
+
: undefined;
|
|
3106
4274
|
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
4275
|
+
return {
|
|
4276
|
+
id: option.id,
|
|
4277
|
+
name: option.name,
|
|
4278
|
+
price: {
|
|
4279
|
+
amount:
|
|
4280
|
+
typeof amount === 'number' && Number.isFinite(amount)
|
|
4281
|
+
? amount.toFixed(2)
|
|
4282
|
+
: '',
|
|
4283
|
+
currencyCode,
|
|
4284
|
+
},
|
|
4285
|
+
description: option?.data?.description,
|
|
4286
|
+
estimatedDays,
|
|
4287
|
+
fulfillmentSetType: option?.service_zone?.fulfillment_set?.type ?? null,
|
|
4288
|
+
};
|
|
3112
4289
|
});
|
|
3113
|
-
|
|
3114
|
-
return methods;
|
|
3115
4290
|
},
|
|
3116
4291
|
'getShippingMethods',
|
|
3117
4292
|
{ cartId },
|
|
3118
4293
|
);
|
|
3119
4294
|
} catch (error: any) {
|
|
3120
|
-
this.logger.error('
|
|
4295
|
+
this.logger.error('Failed to fetch shipping methods', {
|
|
3121
4296
|
cartId,
|
|
3122
4297
|
errorMessage: error.message,
|
|
3123
|
-
errorStack: error.stack,
|
|
3124
4298
|
errorStatus: error.status,
|
|
3125
4299
|
});
|
|
3126
4300
|
throw new ProviderAPIError(
|
|
@@ -3571,22 +4745,19 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3571
4745
|
this.logger.debug('addShippingMethod', { cartId, methodId });
|
|
3572
4746
|
|
|
3573
4747
|
try {
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
'addShippingMethod',
|
|
3588
|
-
{ cartId, methodId },
|
|
3589
|
-
);
|
|
4748
|
+
// Medusa v2: POST /store/carts/:id/shipping-methods.
|
|
4749
|
+
// This is a non-idempotent mutation, so it is sent exactly once and is
|
|
4750
|
+
// NOT wrapped in a retry (change B, decision 11).
|
|
4751
|
+
await this.sdk.store.cart.addShippingMethod(cartId, {
|
|
4752
|
+
option_id: methodId,
|
|
4753
|
+
} as any);
|
|
4754
|
+
|
|
4755
|
+
this.logger.info('Shipping method added successfully', { cartId, methodId });
|
|
4756
|
+
|
|
4757
|
+
// Re-read the authoritative cart so the recalculated totals come from
|
|
4758
|
+
// the backend rather than the mutation response.
|
|
4759
|
+
const updatedCart = await this.retrieveCartWithLineDetails(cartId);
|
|
4760
|
+
return transformCart(updatedCart);
|
|
3590
4761
|
} catch (error: any) {
|
|
3591
4762
|
this.logger.error('Failed to add shipping method', {
|
|
3592
4763
|
cartId,
|
|
@@ -3611,199 +4782,98 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3611
4782
|
* @throws {ProviderAPIError} If provider invalid or cart not ready
|
|
3612
4783
|
*/
|
|
3613
4784
|
async initiatePaymentSession(cartId: string, providerId: string): Promise<void> {
|
|
3614
|
-
this.logger.
|
|
4785
|
+
this.logger.debug('initiatePaymentSession', { cartId, providerId });
|
|
3615
4786
|
|
|
3616
4787
|
try {
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
);
|
|
3625
|
-
const { cart: rawCart } = await this.sdk.store.cart.retrieve(cartId, {
|
|
3626
|
-
fields:
|
|
3627
|
-
'+shipping_methods,+shipping_address,+email,+region_id,+total,+items,+payment_collection,+payment_collection.payment_sessions,+billing_address',
|
|
3628
|
-
});
|
|
3629
|
-
|
|
3630
|
-
this.logger.info('💳 [DEBUG] Step 2: RAW cart retrieved, checking state...', {
|
|
3631
|
-
cartId: rawCart.id,
|
|
3632
|
-
hasShippingMethod: !!rawCart.shipping_methods?.length,
|
|
3633
|
-
shippingMethodIds: rawCart.shipping_methods?.map((sm: any) => sm.id) || [],
|
|
3634
|
-
hasShippingAddress: !!rawCart.shipping_address,
|
|
3635
|
-
shippingAddressCity: rawCart.shipping_address?.city || 'N/A',
|
|
3636
|
-
hasEmail: !!rawCart.email,
|
|
3637
|
-
email: rawCart.email || 'N/A',
|
|
3638
|
-
hasBillingAddress: !!rawCart.billing_address,
|
|
3639
|
-
hasPaymentCollection: !!rawCart.payment_collection,
|
|
3640
|
-
existingSessionsCount: rawCart.payment_collection?.payment_sessions?.length || 0,
|
|
3641
|
-
regionId: rawCart.region_id,
|
|
3642
|
-
total: rawCart.total,
|
|
3643
|
-
itemCount: rawCart.items?.length || 0,
|
|
3644
|
-
});
|
|
3645
|
-
|
|
3646
|
-
// 🛡️ VALIDATION: Cart must have items to create payment session
|
|
3647
|
-
if (!rawCart.items || rawCart.items.length === 0) {
|
|
3648
|
-
this.logger.error('💳 [DEBUG] ERROR: Cannot create payment session for empty cart', {
|
|
3649
|
-
cartId,
|
|
3650
|
-
itemCount: 0,
|
|
3651
|
-
reason: 'Cart has no items',
|
|
3652
|
-
});
|
|
3653
|
-
throw new ProviderAPIError(
|
|
3654
|
-
'Cannot create payment session: cart is empty. Please add items to your cart before checking out.',
|
|
3655
|
-
'medusa',
|
|
3656
|
-
400,
|
|
3657
|
-
);
|
|
3658
|
-
}
|
|
3659
|
-
|
|
3660
|
-
// 🛡️ VALIDATION: Required fields
|
|
3661
|
-
if (!rawCart.shipping_address) {
|
|
3662
|
-
this.logger.error('💳 [DEBUG] ERROR: Shipping address required', { cartId });
|
|
3663
|
-
throw new ProviderAPIError(
|
|
3664
|
-
'Shipping address is required before initiating payment session',
|
|
3665
|
-
'medusa',
|
|
3666
|
-
400,
|
|
3667
|
-
);
|
|
3668
|
-
}
|
|
3669
|
-
|
|
3670
|
-
if (!rawCart.shipping_methods?.length) {
|
|
3671
|
-
this.logger.error('💳 [DEBUG] ERROR: Shipping method required', { cartId });
|
|
3672
|
-
throw new ProviderAPIError(
|
|
3673
|
-
'Shipping method is required before initiating payment session',
|
|
3674
|
-
'medusa',
|
|
3675
|
-
400,
|
|
3676
|
-
);
|
|
3677
|
-
}
|
|
3678
|
-
|
|
3679
|
-
if (!rawCart.email) {
|
|
3680
|
-
this.logger.error('💳 [DEBUG] ERROR: Email required', { cartId });
|
|
3681
|
-
throw new ProviderAPIError(
|
|
3682
|
-
'Email is required before initiating payment session',
|
|
3683
|
-
'medusa',
|
|
3684
|
-
400,
|
|
3685
|
-
);
|
|
3686
|
-
}
|
|
3687
|
-
|
|
3688
|
-
// CHANGE 2: Validate billing address and warn if missing
|
|
3689
|
-
if (!rawCart.billing_address) {
|
|
3690
|
-
this.logger.warn(
|
|
3691
|
-
'💳 [DEBUG] WARNING: Billing address not set, payment provider may require it',
|
|
3692
|
-
{
|
|
3693
|
-
cartId: rawCart.id,
|
|
3694
|
-
shippingAddress: rawCart.shipping_address,
|
|
3695
|
-
},
|
|
3696
|
-
);
|
|
3697
|
-
}
|
|
3698
|
-
|
|
3699
|
-
// CHANGE 3: Check for existing payment sessions (idempotency)
|
|
3700
|
-
// Note: payment_collection is created automatically by Medusa when calling initiatePaymentSession
|
|
3701
|
-
// So we don't need to validate its existence - just check if sessions already exist
|
|
3702
|
-
const existingSessions = rawCart.payment_collection?.payment_sessions || [];
|
|
4788
|
+
// Read the raw cart: the canonical transform does not carry the payment
|
|
4789
|
+
// collection, and the session compatibility check needs the backend's own
|
|
4790
|
+
// session shape (provider, amount, status).
|
|
4791
|
+
const { cart: rawCart } = await this.sdk.store.cart.retrieve(cartId, {
|
|
4792
|
+
fields:
|
|
4793
|
+
'+shipping_methods,+shipping_address,+email,+region_id,+total,+items,+payment_collection,+payment_collection.payment_sessions,+billing_address',
|
|
4794
|
+
});
|
|
3703
4795
|
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
4796
|
+
if (!rawCart.items || rawCart.items.length === 0) {
|
|
4797
|
+
throw new ProviderAPIError(
|
|
4798
|
+
'Cannot create payment session: cart is empty. Please add items to your cart before checking out.',
|
|
4799
|
+
'medusa',
|
|
4800
|
+
400,
|
|
4801
|
+
);
|
|
4802
|
+
}
|
|
3710
4803
|
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
4804
|
+
if (!rawCart.shipping_address) {
|
|
4805
|
+
throw new ProviderAPIError(
|
|
4806
|
+
'Shipping address is required before initiating payment session',
|
|
4807
|
+
'medusa',
|
|
4808
|
+
400,
|
|
4809
|
+
);
|
|
4810
|
+
}
|
|
3714
4811
|
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
4812
|
+
// Payment is applied only after shipping totals are final (decision 10).
|
|
4813
|
+
if (!rawCart.shipping_methods?.length) {
|
|
4814
|
+
throw new ProviderAPIError(
|
|
4815
|
+
'Shipping method is required before initiating payment session',
|
|
4816
|
+
'medusa',
|
|
4817
|
+
400,
|
|
4818
|
+
);
|
|
4819
|
+
}
|
|
3720
4820
|
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
4821
|
+
if (!rawCart.email) {
|
|
4822
|
+
throw new ProviderAPIError(
|
|
4823
|
+
'Email is required before initiating payment session',
|
|
4824
|
+
'medusa',
|
|
4825
|
+
400,
|
|
4826
|
+
);
|
|
4827
|
+
}
|
|
3728
4828
|
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
4829
|
+
validatePaymentId(providerId);
|
|
4830
|
+
|
|
4831
|
+
// Reuse an existing session ONLY when its provider, state and amount are
|
|
4832
|
+
// compatible with the current cart; otherwise create/replace it.
|
|
4833
|
+
const existingSessions: any[] = rawCart.payment_collection?.payment_sessions ?? [];
|
|
4834
|
+
const compatible = existingSessions.find((session: any) => {
|
|
4835
|
+
const statusOk = ['pending', 'requires_more', 'authorized'].includes(session?.status);
|
|
4836
|
+
return (
|
|
4837
|
+
session?.provider_id === providerId &&
|
|
4838
|
+
session?.amount === rawCart.total &&
|
|
4839
|
+
statusOk
|
|
4840
|
+
);
|
|
4841
|
+
});
|
|
3734
4842
|
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
throw new ProviderAPIError(
|
|
3745
|
-
validationError.message,
|
|
3746
|
-
'medusa',
|
|
3747
|
-
400
|
|
3748
|
-
);
|
|
3749
|
-
}
|
|
4843
|
+
if (compatible) {
|
|
4844
|
+
this.logger.info('Reusing compatible payment session', {
|
|
4845
|
+
cartId,
|
|
4846
|
+
providerId,
|
|
4847
|
+
sessionId: compatible.id,
|
|
4848
|
+
amount: compatible.amount,
|
|
4849
|
+
});
|
|
4850
|
+
return;
|
|
4851
|
+
}
|
|
3750
4852
|
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
4853
|
+
if (existingSessions.length > 0) {
|
|
4854
|
+
this.logger.info('Existing payment session incompatible; replacing it', {
|
|
4855
|
+
cartId,
|
|
4856
|
+
providerId,
|
|
4857
|
+
existing: existingSessions.map((session: any) => ({
|
|
4858
|
+
id: session.id,
|
|
4859
|
+
providerId: session.provider_id,
|
|
4860
|
+
amount: session.amount,
|
|
4861
|
+
status: session.status,
|
|
4862
|
+
})),
|
|
4863
|
+
});
|
|
4864
|
+
}
|
|
3756
4865
|
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
response: JSON.stringify(paymentResponse, null, 2),
|
|
3762
|
-
});
|
|
3763
|
-
} catch (sdkError: any) {
|
|
3764
|
-
// CHANGE 4: Enhanced error context
|
|
3765
|
-
this.logger.error(
|
|
3766
|
-
'💳 [DEBUG] ERROR: Medusa SDK threw error when initiating payment session',
|
|
3767
|
-
{
|
|
3768
|
-
cartId,
|
|
3769
|
-
providerId,
|
|
3770
|
-
errorType: sdkError?.constructor?.name,
|
|
3771
|
-
errorMessage: sdkError?.message,
|
|
3772
|
-
errorStatus: sdkError?.status,
|
|
3773
|
-
errorCode: sdkError?.code,
|
|
3774
|
-
errorResponse: sdkError?.response
|
|
3775
|
-
? JSON.stringify(sdkError.response, null, 2)
|
|
3776
|
-
: 'N/A',
|
|
3777
|
-
errorBody: sdkError?.body ? JSON.stringify(sdkError.body, null, 2) : 'N/A',
|
|
3778
|
-
fullError: JSON.stringify(sdkError, Object.getOwnPropertyNames(sdkError), 2),
|
|
3779
|
-
// Include cart state in error for debugging
|
|
3780
|
-
cartState: {
|
|
3781
|
-
hasShippingMethod: !!rawCart.shipping_methods?.length,
|
|
3782
|
-
hasShippingAddress: !!rawCart.shipping_address,
|
|
3783
|
-
hasBillingAddress: !!rawCart.billing_address,
|
|
3784
|
-
hasEmail: !!rawCart.email,
|
|
3785
|
-
total: rawCart.total,
|
|
3786
|
-
itemCount: rawCart.items?.length || 0,
|
|
3787
|
-
},
|
|
3788
|
-
},
|
|
3789
|
-
);
|
|
3790
|
-
throw sdkError;
|
|
3791
|
-
}
|
|
4866
|
+
// Non-idempotent mutation: sent exactly once, never retried.
|
|
4867
|
+
await this.sdk.store.payment.initiatePaymentSession(rawCart as any, {
|
|
4868
|
+
provider_id: providerId,
|
|
4869
|
+
});
|
|
3792
4870
|
|
|
3793
|
-
|
|
3794
|
-
cartId,
|
|
3795
|
-
providerId,
|
|
3796
|
-
});
|
|
3797
|
-
},
|
|
3798
|
-
'initiatePaymentSession',
|
|
3799
|
-
{ cartId, providerId },
|
|
3800
|
-
);
|
|
4871
|
+
this.logger.info('Payment session initiated', { cartId, providerId });
|
|
3801
4872
|
} catch (error: any) {
|
|
3802
|
-
this.logger.error('
|
|
4873
|
+
this.logger.error('Failed to initiate payment session', {
|
|
3803
4874
|
cartId,
|
|
3804
4875
|
providerId,
|
|
3805
4876
|
errorMessage: error.message,
|
|
3806
|
-
errorStack: error.stack,
|
|
3807
4877
|
errorStatus: error.status,
|
|
3808
4878
|
});
|
|
3809
4879
|
throw new ProviderAPIError(
|
|
@@ -3828,36 +4898,28 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3828
4898
|
this.logger.debug('applyDiscountCode', { cartId, code });
|
|
3829
4899
|
|
|
3830
4900
|
try {
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
});
|
|
3844
|
-
|
|
3845
|
-
if (!response.ok) {
|
|
3846
|
-
const error = await response.json();
|
|
3847
|
-
throw new Error(error.message || 'Failed to apply discount code');
|
|
3848
|
-
}
|
|
4901
|
+
// Medusa v2: POST /store/carts/:id/promotions.
|
|
4902
|
+
// Non-idempotent mutation: sent exactly once, never retried.
|
|
4903
|
+
const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
|
|
4904
|
+
method: 'POST',
|
|
4905
|
+
headers: {
|
|
4906
|
+
'Content-Type': 'application/json',
|
|
4907
|
+
'x-publishable-api-key': this.publishableKey || '',
|
|
4908
|
+
},
|
|
4909
|
+
body: JSON.stringify({
|
|
4910
|
+
promo_codes: [code],
|
|
4911
|
+
}),
|
|
4912
|
+
});
|
|
3849
4913
|
|
|
3850
|
-
|
|
4914
|
+
if (!response.ok) {
|
|
4915
|
+
const error = await response.json();
|
|
4916
|
+
throw new Error(error.message || 'Failed to apply discount code');
|
|
4917
|
+
}
|
|
3851
4918
|
|
|
3852
|
-
|
|
4919
|
+
this.logger.info('Discount code applied successfully', { cartId, code });
|
|
3853
4920
|
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
return transformCart(updatedCart);
|
|
3857
|
-
},
|
|
3858
|
-
'applyDiscountCode',
|
|
3859
|
-
{ cartId, code },
|
|
3860
|
-
);
|
|
4921
|
+
const updatedCart = await this.retrieveCartWithLineDetails(cartId);
|
|
4922
|
+
return transformCart(updatedCart);
|
|
3861
4923
|
} catch (error: any) {
|
|
3862
4924
|
this.logger.error('Failed to apply discount code', { cartId, code, error: error.message });
|
|
3863
4925
|
throw new ProviderAPIError(
|
|
@@ -3882,36 +4944,28 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3882
4944
|
this.logger.debug('removeDiscountCode', { cartId, code });
|
|
3883
4945
|
|
|
3884
4946
|
try {
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
});
|
|
3898
|
-
|
|
3899
|
-
if (!response.ok) {
|
|
3900
|
-
const error = await response.json();
|
|
3901
|
-
throw new Error(error.message || 'Failed to remove discount code');
|
|
3902
|
-
}
|
|
4947
|
+
// Medusa v2: DELETE /store/carts/:id/promotions.
|
|
4948
|
+
// Non-idempotent mutation: sent exactly once, never retried.
|
|
4949
|
+
const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
|
|
4950
|
+
method: 'DELETE',
|
|
4951
|
+
headers: {
|
|
4952
|
+
'Content-Type': 'application/json',
|
|
4953
|
+
'x-publishable-api-key': this.publishableKey || '',
|
|
4954
|
+
},
|
|
4955
|
+
body: JSON.stringify({
|
|
4956
|
+
promo_codes: [code],
|
|
4957
|
+
}),
|
|
4958
|
+
});
|
|
3903
4959
|
|
|
3904
|
-
|
|
4960
|
+
if (!response.ok) {
|
|
4961
|
+
const error = await response.json();
|
|
4962
|
+
throw new Error(error.message || 'Failed to remove discount code');
|
|
4963
|
+
}
|
|
3905
4964
|
|
|
3906
|
-
|
|
4965
|
+
this.logger.info('Discount code removed successfully', { cartId, code });
|
|
3907
4966
|
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
return transformCart(updatedCart);
|
|
3911
|
-
},
|
|
3912
|
-
'removeDiscountCode',
|
|
3913
|
-
{ cartId, code },
|
|
3914
|
-
);
|
|
4967
|
+
const updatedCart = await this.retrieveCartWithLineDetails(cartId);
|
|
4968
|
+
return transformCart(updatedCart);
|
|
3915
4969
|
} catch (error: any) {
|
|
3916
4970
|
this.logger.error('Failed to remove discount code', { cartId, code, error: error.message });
|
|
3917
4971
|
throw new ProviderAPIError(
|