@thorprovider/adapters 5.0.9 → 5.0.11
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/.turbo/turbo-build.log +18 -0
- package/.turbo/turbo-test.log +897 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -1
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.mts +348 -11
- package/dist/index.d.ts +348 -11
- package/dist/index.js +1393 -496
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1386 -498
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/publish.log +93 -0
- 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/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/index.test.ts +480 -0
- package/src/providers/medusa/index.ts +1490 -518
- package/src/providers/medusa/live-smoke.test.ts +68 -0
- package/src/providers/medusa/transforms.test.ts +177 -0
- package/src/providers/medusa/transforms.ts +145 -66
- package/src/providers/mock/catalog-query.test.ts +355 -0
- package/src/providers/mock/index.test.ts +47 -0
- package/src/providers/mock/index.ts +43 -1
- package/src/providers/mock/mock.ts +324 -2
- package/src/types/completion.ts +57 -0
- package/src/types/index.ts +7 -0
|
@@ -38,9 +38,20 @@ import type {
|
|
|
38
38
|
StorefrontConfig,
|
|
39
39
|
DesignerConfig,
|
|
40
40
|
DesignerHistoryEntry,
|
|
41
|
+
CatalogQuery,
|
|
42
|
+
CatalogAppliedFilters,
|
|
43
|
+
CatalogQueryOutcome,
|
|
44
|
+
CatalogFacetId,
|
|
45
|
+
CatalogFacetDemonstrationSource,
|
|
46
|
+
FacetSupport,
|
|
41
47
|
} from '@thorprovider/types';
|
|
42
|
-
import { StorefrontConfigError } from '
|
|
48
|
+
import { StorefrontConfigError } from '../../core/errors';
|
|
43
49
|
import type { CommerceProvider, ProviderConfig } from '../../types/provider';
|
|
50
|
+
import type {
|
|
51
|
+
CartCompletionCapable,
|
|
52
|
+
CartCompletionError,
|
|
53
|
+
CartCompletionResult,
|
|
54
|
+
} from '../../types/completion';
|
|
44
55
|
import { MEDUSA_CAPABILITIES } from '../../types/capabilities.types';
|
|
45
56
|
import { createLogger } from '../../core/logger';
|
|
46
57
|
import { fetchWithRetry, type RetryConfig } from '../../core/fetch-utils';
|
|
@@ -56,7 +67,344 @@ import {
|
|
|
56
67
|
} from '../../metadata';
|
|
57
68
|
|
|
58
69
|
const MEDUSA_CART_RETRIEVE_FIELDS =
|
|
59
|
-
'+items,+items.variant,+items.variant.product,+items.variant.product.thumbnail,+items.variant.options,+items.variant.options.option';
|
|
70
|
+
'+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';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fields requested when completing a cart into an order.
|
|
74
|
+
*
|
|
75
|
+
* The native `complete` default projection is minimal, so the returned order's
|
|
76
|
+
* `items` are empty. `*items` selects all fields of the `items` relation
|
|
77
|
+
* (Medusa field syntax: `*` retrieves all fields of a relation), which yields
|
|
78
|
+
* each line's `id`, `title`, `product_title`, `variant_title`, `quantity`,
|
|
79
|
+
* `unit_price` and `total` (all major units, backend-authoritative). The same
|
|
80
|
+
* query is applied when completion returns a cart-with-error, and `*items` is
|
|
81
|
+
* valid for both entities.
|
|
82
|
+
*/
|
|
83
|
+
const MEDUSA_ORDER_COMPLETION_FIELDS = '*items';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Per-request catalog listing bound (change B, decision 5).
|
|
87
|
+
*
|
|
88
|
+
* Listing is paginated with a configured per-request bound instead of an
|
|
89
|
+
* unbounded scan. Live probes against the acceptance backend accepted
|
|
90
|
+
* `limit` 1/50/100/200/1000, so 50 is a deliberately conservative *configured*
|
|
91
|
+
* bound — NOT a claimed backend maximum. Override with
|
|
92
|
+
* `MEDUSA_CATALOG_PAGE_SIZE` or `MedusaProviderConfig.catalogPageSize`.
|
|
93
|
+
*/
|
|
94
|
+
const DEFAULT_CATALOG_PAGE_SIZE = 50;
|
|
95
|
+
|
|
96
|
+
function resolveCatalogPageSize(configured?: number): number {
|
|
97
|
+
const fromEnv =
|
|
98
|
+
process.env.MEDUSA_CATALOG_PAGE_SIZE !== undefined
|
|
99
|
+
? Number(process.env.MEDUSA_CATALOG_PAGE_SIZE)
|
|
100
|
+
: undefined;
|
|
101
|
+
const raw = configured ?? fromEnv;
|
|
102
|
+
if (raw === undefined || !Number.isFinite(raw) || raw < 1) {
|
|
103
|
+
return DEFAULT_CATALOG_PAGE_SIZE;
|
|
104
|
+
}
|
|
105
|
+
return Math.floor(raw);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the configured sales channel for cart scoping.
|
|
110
|
+
*
|
|
111
|
+
* Prefers the server-only `MEDUSA_SALES_CHANNEL_ID`, then the legacy public
|
|
112
|
+
* `NEXT_PUBLIC_SALES_CHANNEL_ID`. Returns `undefined` when neither is set, so
|
|
113
|
+
* the caller can fall back to the backend's publishable-key channel.
|
|
114
|
+
*/
|
|
115
|
+
function resolveConfiguredSalesChannelId(): string | undefined {
|
|
116
|
+
return (
|
|
117
|
+
process.env.MEDUSA_SALES_CHANNEL_ID ??
|
|
118
|
+
process.env.NEXT_PUBLIC_SALES_CHANNEL_ID
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Canonical Thor sort keys → Medusa Store `/store/products` `order` field names.
|
|
124
|
+
*
|
|
125
|
+
* Medusa's Store API only accepts fields on the route's allowed list (for
|
|
126
|
+
* products: `title`, `created_at`, …) and fails a request with a 400 when the
|
|
127
|
+
* field is not allowed (Store API pagination/sort contract). Thor's canonical
|
|
128
|
+
* keys therefore cannot be passed through raw:
|
|
129
|
+
*
|
|
130
|
+
* - `featured` → no `order` (server default ordering)
|
|
131
|
+
* - `name` → `title`
|
|
132
|
+
* - `newest` → `-created_at`
|
|
133
|
+
*
|
|
134
|
+
* Legacy `SortOption` keys (`price-asc`, `price-desc`, `rating`) are not
|
|
135
|
+
* supported at the query level and map to `null` (server default), preserving
|
|
136
|
+
* the existing client-side behavior instead of sending an invalid order field.
|
|
137
|
+
* `null` means "omit `order` entirely".
|
|
138
|
+
*/
|
|
139
|
+
const MEDUSA_SORT_KEY_MAP: Record<string, string | null> = {
|
|
140
|
+
featured: null,
|
|
141
|
+
name: 'title',
|
|
142
|
+
newest: '-created_at',
|
|
143
|
+
'price-asc': null,
|
|
144
|
+
'price-desc': null,
|
|
145
|
+
rating: null,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Effective category scope for a canonical query (E2 task 2.2).
|
|
150
|
+
*
|
|
151
|
+
* The route category scope (`categoryScope`) is always retained; the
|
|
152
|
+
* `categories` filter narrows within it. Returns `undefined` when no category
|
|
153
|
+
* filter is requested, `[]` for an explicit empty intersection (a valid
|
|
154
|
+
* zero-result query), or the handles to resolve to backend IDs.
|
|
155
|
+
*/
|
|
156
|
+
function resolveEffectiveCategoryHandles(
|
|
157
|
+
query: CatalogQuery,
|
|
158
|
+
): string[] | undefined {
|
|
159
|
+
const { categoryScope, categories } = query;
|
|
160
|
+
if (categoryScope === undefined) return categories;
|
|
161
|
+
if (categories === undefined) return [categoryScope];
|
|
162
|
+
return categories.filter((handle) => handle === categoryScope);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Filters a successful (or explicitly-empty) canonical result reports as
|
|
167
|
+
* applied. Every requested filter that the adapter actually applied appears
|
|
168
|
+
* here; unappliable filters never reach this path (they return a typed error).
|
|
169
|
+
*
|
|
170
|
+
* `includeAdvanced` is set only on a path that genuinely applied the requested
|
|
171
|
+
* price/stock/attribute filters (the advanced endpoint execution, or a
|
|
172
|
+
* zero-result scope short-circuit where the requested filters are moot because
|
|
173
|
+
* the scope already matches nothing). The basic `/store/products` path never
|
|
174
|
+
* includes them — it does not accept those parameters.
|
|
175
|
+
*/
|
|
176
|
+
function buildAppliedCatalogFilters(
|
|
177
|
+
query: CatalogQuery,
|
|
178
|
+
effectiveCategoryHandles: string[] | undefined,
|
|
179
|
+
includeAdvanced = false,
|
|
180
|
+
): CatalogAppliedFilters {
|
|
181
|
+
return {
|
|
182
|
+
...(query.text ? { text: query.text } : {}),
|
|
183
|
+
...(effectiveCategoryHandles !== undefined
|
|
184
|
+
? { categories: effectiveCategoryHandles }
|
|
185
|
+
: {}),
|
|
186
|
+
...(query.collections !== undefined
|
|
187
|
+
? { collections: query.collections }
|
|
188
|
+
: {}),
|
|
189
|
+
...(includeAdvanced && query.priceMin !== undefined
|
|
190
|
+
? { priceMin: query.priceMin }
|
|
191
|
+
: {}),
|
|
192
|
+
...(includeAdvanced && query.priceMax !== undefined
|
|
193
|
+
? { priceMax: query.priceMax }
|
|
194
|
+
: {}),
|
|
195
|
+
...(includeAdvanced && query.inStock !== undefined
|
|
196
|
+
? { inStock: query.inStock }
|
|
197
|
+
: {}),
|
|
198
|
+
...(includeAdvanced && query.attributes !== undefined
|
|
199
|
+
? { attributes: query.attributes }
|
|
200
|
+
: {}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Basic fields the canonical `/store/products` endpoint actually accepts. A
|
|
206
|
+
* basic demonstration's `appliedQuery` is limited to these; price/stock/
|
|
207
|
+
* attribute filters are never recorded as applied by the basic endpoint because
|
|
208
|
+
* it does not accept them.
|
|
209
|
+
*/
|
|
210
|
+
function buildBasicDemonstratedQuery(query: CatalogQuery): CatalogQuery {
|
|
211
|
+
return {
|
|
212
|
+
...(query.text !== undefined ? { text: query.text } : {}),
|
|
213
|
+
...(query.categoryScope !== undefined
|
|
214
|
+
? { categoryScope: query.categoryScope }
|
|
215
|
+
: {}),
|
|
216
|
+
...(query.categories !== undefined ? { categories: query.categories } : {}),
|
|
217
|
+
...(query.collections !== undefined
|
|
218
|
+
? { collections: query.collections }
|
|
219
|
+
: {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The optional advanced listing endpoint. Medusa's Store `/store/products`
|
|
225
|
+
* endpoint (the equivalent basic endpoint) applies category/collection/text/
|
|
226
|
+
* sort/pagination only; price, stock, and attribute filters require this
|
|
227
|
+
* separate search plugin (E2 Decision 5). Its presence is proven by a real
|
|
228
|
+
* request, never by a capability boolean or returned facet metadata.
|
|
229
|
+
*/
|
|
230
|
+
const ADVANCED_SEARCH_ENDPOINT = '/store/search';
|
|
231
|
+
|
|
232
|
+
/** The canonical basic listing endpoint that serves a basic-only request. */
|
|
233
|
+
const BASIC_LISTING_ENDPOINT = '/store/products';
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Canonical query fields that require the optional advanced endpoint. Empty
|
|
237
|
+
* means the request is basic-only and can be served by `/store/products`.
|
|
238
|
+
*/
|
|
239
|
+
function collectRequestedAdvancedFields(query: CatalogQuery): string[] {
|
|
240
|
+
const fields: string[] = [];
|
|
241
|
+
if (query.priceMin !== undefined) fields.push('priceMin');
|
|
242
|
+
if (query.priceMax !== undefined) fields.push('priceMax');
|
|
243
|
+
if (query.inStock !== undefined) fields.push('inStock');
|
|
244
|
+
for (const name of Object.keys(query.attributes ?? {})) {
|
|
245
|
+
fields.push(`attr.${name}`);
|
|
246
|
+
}
|
|
247
|
+
return fields;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Map a requested advanced field name to the canonical facet id it needs. */
|
|
251
|
+
function advancedFieldToFacet(field: string): CatalogFacetId {
|
|
252
|
+
if (field === 'priceMin' || field === 'priceMax') return 'price';
|
|
253
|
+
return field as CatalogFacetId;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Advanced fields requested through the legacy `searchProductsAdvanced` options.
|
|
258
|
+
* Empty means the caller asked for a basic-only search that `getProducts` can
|
|
259
|
+
* faithfully serve as a fallback.
|
|
260
|
+
*/
|
|
261
|
+
function collectAdvancedSearchRequestFields(
|
|
262
|
+
options?: AdvancedSearchProductsOptions,
|
|
263
|
+
): string[] {
|
|
264
|
+
const fields: string[] = [];
|
|
265
|
+
if (options?.priceMin !== undefined) fields.push('priceMin');
|
|
266
|
+
if (options?.priceMax !== undefined) fields.push('priceMax');
|
|
267
|
+
if (options?.inStockOnly === true) fields.push('inStock');
|
|
268
|
+
if (options?.attributes && options.attributes.length > 0) {
|
|
269
|
+
fields.push('attributes');
|
|
270
|
+
}
|
|
271
|
+
return fields;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Parse a value the backend may serialize as a number or a numeric string into
|
|
276
|
+
* a finite number, or `undefined`. Shared by the applied-filter readers so a
|
|
277
|
+
* non-finite or missing bound is never treated as an applied filter.
|
|
278
|
+
*/
|
|
279
|
+
function toFiniteNumber(value: unknown): number | undefined {
|
|
280
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
281
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
282
|
+
const parsed = Number(value);
|
|
283
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Read the real backend's `applied_filters.price_range` object (`{ min, max }`,
|
|
290
|
+
* or `null` when no price filter was applied) into finite bounds. A missing,
|
|
291
|
+
* non-object, or non-finite bound yields `undefined` so the price facet is never
|
|
292
|
+
* fabricated (E2 Decision 2: price requires a finite, ordered range).
|
|
293
|
+
*/
|
|
294
|
+
function readAppliedPriceRange(
|
|
295
|
+
applied: Record<string, unknown>,
|
|
296
|
+
): { min?: number; max?: number } {
|
|
297
|
+
const range = applied.price_range;
|
|
298
|
+
if (!range || typeof range !== 'object' || Array.isArray(range)) return {};
|
|
299
|
+
const record = range as Record<string, unknown>;
|
|
300
|
+
return { min: toFiniteNumber(record.min), max: toFiniteNumber(record.max) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Read the filters a real advanced-endpoint response echoes as applied. Only
|
|
305
|
+
* explicit `applied_filters`/`appliedFilters` keys count as proof; a response
|
|
306
|
+
* that omits them demonstrates nothing and the facet stays held (E2 Decision 5:
|
|
307
|
+
* returned facet metadata is not proof).
|
|
308
|
+
*
|
|
309
|
+
* The real backend emits `price_range` (object) and `is_available` (boolean);
|
|
310
|
+
* the documented legacy aliases (`price`/`price_min`/`price_max`,
|
|
311
|
+
* `inStock`/`isAvailable`/`available`) stay recognized (E2 Decision 1).
|
|
312
|
+
*/
|
|
313
|
+
function mapAppliedFiltersToFacets(
|
|
314
|
+
applied: Record<string, unknown>,
|
|
315
|
+
): Set<CatalogFacetId> {
|
|
316
|
+
const facets = new Set<CatalogFacetId>();
|
|
317
|
+
const priceRange = readAppliedPriceRange(applied);
|
|
318
|
+
const hasAppliedPriceRange =
|
|
319
|
+
priceRange.min !== undefined && priceRange.max !== undefined;
|
|
320
|
+
if (
|
|
321
|
+
hasAppliedPriceRange ||
|
|
322
|
+
'price' in applied ||
|
|
323
|
+
'price_min' in applied ||
|
|
324
|
+
'price_max' in applied
|
|
325
|
+
) {
|
|
326
|
+
facets.add('price');
|
|
327
|
+
}
|
|
328
|
+
if (
|
|
329
|
+
typeof applied.is_available === 'boolean' ||
|
|
330
|
+
'inStock' in applied ||
|
|
331
|
+
'isAvailable' in applied ||
|
|
332
|
+
'available' in applied
|
|
333
|
+
) {
|
|
334
|
+
facets.add('inStock');
|
|
335
|
+
}
|
|
336
|
+
const attributes = applied.attributes;
|
|
337
|
+
if (attributes && typeof attributes === 'object' && !Array.isArray(attributes)) {
|
|
338
|
+
for (const name of Object.keys(attributes)) {
|
|
339
|
+
facets.add(`attr.${name}` as CatalogFacetId);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return facets;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Requested advanced fields the actual advanced-endpoint response did not
|
|
347
|
+
* confirm as applied. A probe echo alone cannot guarantee the listing request
|
|
348
|
+
* applied the filter, so the executed response is re-validated against every
|
|
349
|
+
* requested field (value equality where the backend echoes a scalar) before a
|
|
350
|
+
* result is reported as success (E2 task 2.4).
|
|
351
|
+
*/
|
|
352
|
+
function collectUnappliedAdvancedFields(
|
|
353
|
+
applied: Record<string, unknown>,
|
|
354
|
+
query: CatalogQuery,
|
|
355
|
+
): string[] {
|
|
356
|
+
const unapplied: string[] = [];
|
|
357
|
+
|
|
358
|
+
const priceRange = readAppliedPriceRange(applied);
|
|
359
|
+
|
|
360
|
+
if (query.priceMin !== undefined) {
|
|
361
|
+
const echoed = toFiniteNumber(
|
|
362
|
+
priceRange.min ?? applied.price_min ?? applied.priceMin,
|
|
363
|
+
);
|
|
364
|
+
if (echoed !== query.priceMin) unapplied.push('priceMin');
|
|
365
|
+
}
|
|
366
|
+
if (query.priceMax !== undefined) {
|
|
367
|
+
const echoed = toFiniteNumber(
|
|
368
|
+
priceRange.max ?? applied.price_max ?? applied.priceMax,
|
|
369
|
+
);
|
|
370
|
+
if (echoed !== query.priceMax) unapplied.push('priceMax');
|
|
371
|
+
}
|
|
372
|
+
if (query.inStock === true) {
|
|
373
|
+
const echoed =
|
|
374
|
+
applied.is_available ??
|
|
375
|
+
applied.isAvailable ??
|
|
376
|
+
applied.inStock ??
|
|
377
|
+
applied.available;
|
|
378
|
+
if (echoed !== true && echoed !== 'true') unapplied.push('inStock');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const requestedAttributes = query.attributes ?? {};
|
|
382
|
+
const appliedAttributes = applied.attributes;
|
|
383
|
+
for (const name of Object.keys(requestedAttributes)) {
|
|
384
|
+
const hasName =
|
|
385
|
+
!!appliedAttributes &&
|
|
386
|
+
typeof appliedAttributes === 'object' &&
|
|
387
|
+
!Array.isArray(appliedAttributes) &&
|
|
388
|
+
name in (appliedAttributes as Record<string, unknown>);
|
|
389
|
+
if (!hasName) unapplied.push(`attr.${name}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return unapplied;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Read a backend-supplied numeric facet value (range bound or option count).
|
|
397
|
+
*
|
|
398
|
+
* Returns `undefined` when the backend did not supply a finite number, so
|
|
399
|
+
* callers can omit the value entirely instead of inventing a default. This is
|
|
400
|
+
* the guard that keeps `getAvailableFilters` from fabricating ranges or counts
|
|
401
|
+
* (E2 Decision 4; spec "Facet counts and ranges are never fabricated").
|
|
402
|
+
*/
|
|
403
|
+
function readFiniteFacetNumber(value: unknown): number | undefined {
|
|
404
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
405
|
+
? value
|
|
406
|
+
: undefined;
|
|
407
|
+
}
|
|
60
408
|
|
|
61
409
|
/**
|
|
62
410
|
* Medusa provider configuration
|
|
@@ -85,6 +433,13 @@ export interface MedusaProviderConfig extends ProviderConfig {
|
|
|
85
433
|
*/
|
|
86
434
|
regionId?: string;
|
|
87
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Per-request catalog listing bound. Listing is paginated with this bound
|
|
438
|
+
* instead of an unbounded scan; it is a configured safety bound, not a
|
|
439
|
+
* backend maximum. Defaults to `MEDUSA_CATALOG_PAGE_SIZE` or 50.
|
|
440
|
+
*/
|
|
441
|
+
catalogPageSize?: number;
|
|
442
|
+
|
|
88
443
|
/**
|
|
89
444
|
* Authentication configuration (optional)
|
|
90
445
|
* If not provided, auth methods will not be available
|
|
@@ -126,10 +481,23 @@ export interface MedusaProviderConfig extends ProviderConfig {
|
|
|
126
481
|
*
|
|
127
482
|
* Implements CommerceProvider interface for Medusa.js v2
|
|
128
483
|
*/
|
|
129
|
-
export class MedusaProvider implements CommerceProvider {
|
|
484
|
+
export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
|
|
130
485
|
readonly name = 'medusa';
|
|
131
486
|
readonly version = '2.0.0';
|
|
132
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Canonical catalog sort keys this provider applies at the query level.
|
|
490
|
+
*
|
|
491
|
+
* Advertises only the backend-verified orderings (E2 Decision 6). `price-asc`,
|
|
492
|
+
* `price-desc`, and `rating` are intentionally absent: they are not applied at
|
|
493
|
+
* the query level, so the listing never offers them as global sorts.
|
|
494
|
+
*
|
|
495
|
+
* The literal tuple is assignable to `CommerceProvider['supportedCatalogSortKeys']`
|
|
496
|
+
* (`readonly CatalogSortKey[]`); it is written structurally here so the adapter
|
|
497
|
+
* compiles against the published `@thorprovider/types` declaration.
|
|
498
|
+
*/
|
|
499
|
+
readonly supportedCatalogSortKeys = ['featured', 'name', 'newest'] as const;
|
|
500
|
+
|
|
133
501
|
/**
|
|
134
502
|
* Medusa backend capabilities.
|
|
135
503
|
* Starts with static defaults, replaced by dynamic values from
|
|
@@ -154,6 +522,15 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
154
522
|
private baseUrl: string;
|
|
155
523
|
private publishableKey: string;
|
|
156
524
|
private regionId?: string;
|
|
525
|
+
/** Configured per-request catalog listing bound (never an unbounded scan). */
|
|
526
|
+
private catalogPageSize: number;
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Per-scope cache of the truthful facet-support descriptor. The optional
|
|
530
|
+
* advanced endpoint is probed once per demonstrated scope/query rather than on
|
|
531
|
+
* every listing request (E2 task 2.4).
|
|
532
|
+
*/
|
|
533
|
+
private facetSupportCache = new Map<string, Promise<FacetSupport>>();
|
|
157
534
|
|
|
158
535
|
/** Secret API key for admin operations (if configured). */
|
|
159
536
|
private adminApiKey?: string;
|
|
@@ -212,6 +589,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
212
589
|
this.baseUrl = config.baseUrl;
|
|
213
590
|
this.publishableKey = config.publishableKey;
|
|
214
591
|
this.regionId = config.regionId;
|
|
592
|
+
this.catalogPageSize = resolveCatalogPageSize(config.catalogPageSize);
|
|
215
593
|
|
|
216
594
|
// Store retry configuration
|
|
217
595
|
this.retryConfig = {
|
|
@@ -238,11 +616,22 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
238
616
|
// Always use JWT mode for auth - SDK needs to send Bearer tokens
|
|
239
617
|
// Configure storage method for JWT tokens (browser only)
|
|
240
618
|
auth: config.auth
|
|
241
|
-
?
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
619
|
+
? config.auth.customStorage
|
|
620
|
+
? {
|
|
621
|
+
type: 'jwt',
|
|
622
|
+
// Request-scoped custom storage: the caller supplies a per-request
|
|
623
|
+
// token holder (for example a server request's customer JWT). The
|
|
624
|
+
// SDK reads the token on every request and writes the login /
|
|
625
|
+
// registration token back into the same holder, so a fresh
|
|
626
|
+
// provider per request is authenticated without shared state.
|
|
627
|
+
jwtTokenStorageMethod: 'custom',
|
|
628
|
+
storage: config.auth.customStorage,
|
|
629
|
+
}
|
|
630
|
+
: {
|
|
631
|
+
type: 'jwt',
|
|
632
|
+
// Use localStorage in browser, memory storage on server
|
|
633
|
+
jwtTokenStorageMethod: typeof window !== 'undefined' ? 'local' : 'memory',
|
|
634
|
+
}
|
|
246
635
|
: undefined,
|
|
247
636
|
});
|
|
248
637
|
|
|
@@ -385,45 +774,65 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
385
774
|
throw lastError;
|
|
386
775
|
}
|
|
387
776
|
|
|
388
|
-
|
|
389
|
-
|
|
777
|
+
/** Fields needed to price a product variant for the current region. */
|
|
778
|
+
private buildProductFields(regionId?: string): string {
|
|
779
|
+
// calculated_price requires region_id in Medusa v2 — only request it when available
|
|
780
|
+
const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
|
|
781
|
+
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
782
|
+
return regionId ? baseFields + pricingFields : baseFields;
|
|
783
|
+
}
|
|
390
784
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const fields = regionId ? baseFields + pricingFields : baseFields;
|
|
785
|
+
/** Scope a store query to the configured storefront sales channel, if any. */
|
|
786
|
+
private applySalesChannelScope(queryParams: Record<string, unknown>): void {
|
|
787
|
+
const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
788
|
+
if (salesChannelId) {
|
|
789
|
+
queryParams.sales_channel_id = [salesChannelId];
|
|
790
|
+
}
|
|
791
|
+
}
|
|
399
792
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
793
|
+
/**
|
|
794
|
+
* Directed single-product lookup by a supported native filter (`handle` or
|
|
795
|
+
* `id`). This is independent of catalog listing pagination, so a known
|
|
796
|
+
* product beyond the first listing page still resolves without scanning.
|
|
797
|
+
*
|
|
798
|
+
* Medusa's store API supports `handle` and `id` on `/store/products`; it
|
|
799
|
+
* does not expose a variant filter there, so a variant is resolved from the
|
|
800
|
+
* product once the product is known (see `getProductDetailByHandle` in the
|
|
801
|
+
* runtime). No `limit` beyond 1 is issued.
|
|
802
|
+
*/
|
|
803
|
+
private async fetchSingleProduct(
|
|
804
|
+
filter: { handle?: string; id?: string },
|
|
805
|
+
options?: { regionId?: string },
|
|
806
|
+
): Promise<Product | undefined> {
|
|
807
|
+
const regionId = options?.regionId ?? this.regionId;
|
|
808
|
+
|
|
809
|
+
const queryParams: Record<string, unknown> = {
|
|
810
|
+
...filter,
|
|
811
|
+
limit: 1,
|
|
812
|
+
fields: this.buildProductFields(regionId),
|
|
813
|
+
};
|
|
814
|
+
// Add region_id for region-based pricing
|
|
815
|
+
if (regionId) {
|
|
816
|
+
queryParams.region_id = regionId;
|
|
817
|
+
}
|
|
818
|
+
this.applySalesChannelScope(queryParams);
|
|
409
819
|
|
|
410
|
-
|
|
411
|
-
const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
412
|
-
if (salesChannelId) {
|
|
413
|
-
queryParams.sales_channel_id = [salesChannelId];
|
|
414
|
-
}
|
|
820
|
+
const { products } = await this.sdk.store.product.list(queryParams as any);
|
|
415
821
|
|
|
416
|
-
|
|
822
|
+
if (!products || products.length === 0) {
|
|
823
|
+
this.logger.debug('Product not found', filter);
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
417
826
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return undefined;
|
|
421
|
-
}
|
|
827
|
+
return transformProduct(products[0], this.currencyCode);
|
|
828
|
+
}
|
|
422
829
|
|
|
423
|
-
|
|
830
|
+
async getProduct(handle: string, options?: { regionId?: string }): Promise<Product | undefined> {
|
|
831
|
+
this.logger.debug('getProduct', { handle, regionId: options?.regionId });
|
|
424
832
|
|
|
425
|
-
|
|
426
|
-
|
|
833
|
+
try {
|
|
834
|
+
return await this.withRetry(
|
|
835
|
+
() => this.fetchSingleProduct({ handle }, options),
|
|
427
836
|
'getProduct',
|
|
428
837
|
{ handle, regionId: options?.regionId },
|
|
429
838
|
);
|
|
@@ -436,6 +845,31 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
436
845
|
}
|
|
437
846
|
}
|
|
438
847
|
|
|
848
|
+
/**
|
|
849
|
+
* Directed product lookup by backend product ID. Independent of the first
|
|
850
|
+
* listing page, mirroring `getProduct` for handle-based resolution.
|
|
851
|
+
*/
|
|
852
|
+
async getProductById(
|
|
853
|
+
id: string,
|
|
854
|
+
options?: { regionId?: string },
|
|
855
|
+
): Promise<Product | undefined> {
|
|
856
|
+
this.logger.debug('getProductById', { id, regionId: options?.regionId });
|
|
857
|
+
|
|
858
|
+
try {
|
|
859
|
+
return await this.withRetry(
|
|
860
|
+
() => this.fetchSingleProduct({ id }, options),
|
|
861
|
+
'getProductById',
|
|
862
|
+
{ id, regionId: options?.regionId },
|
|
863
|
+
);
|
|
864
|
+
} catch (error: any) {
|
|
865
|
+
throw new ProviderAPIError(
|
|
866
|
+
`Failed to fetch product by id: ${error.message}`,
|
|
867
|
+
'medusa',
|
|
868
|
+
error.status,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
439
873
|
async getProducts(options: GetProductsOptions & { regionId?: string } = {}): Promise<Product[]> {
|
|
440
874
|
this.logger.debug('getProducts', options);
|
|
441
875
|
|
|
@@ -448,28 +882,25 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
448
882
|
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
449
883
|
const fields = regionId ? baseFields + pricingFields : baseFields;
|
|
450
884
|
|
|
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
|
-
};
|
|
885
|
+
// Map Thor sort keys to Medusa /store/products `order` field names.
|
|
886
|
+
// Native endpoint supports: title, -title, created_at, -created_at.
|
|
887
|
+
// Price/rating sorting is not supported — omitted so products-client
|
|
888
|
+
// handles it client-side as before (see MEDUSA_SORT_KEY_MAP).
|
|
463
889
|
|
|
890
|
+
// Bounded, paginated listing (change B, decision 5): the per-request
|
|
891
|
+
// limit is the configured catalog bound, never an unbounded scan.
|
|
464
892
|
const queryParams: any = {
|
|
465
893
|
q: options.query,
|
|
466
|
-
limit: options.first ??
|
|
467
|
-
offset:
|
|
894
|
+
limit: options.first ?? this.catalogPageSize,
|
|
895
|
+
offset:
|
|
896
|
+
options.after !== undefined
|
|
897
|
+
? parseInt(String(options.after), 10)
|
|
898
|
+
: options.skip,
|
|
468
899
|
fields,
|
|
469
900
|
};
|
|
470
901
|
|
|
471
902
|
if (options.sortKey) {
|
|
472
|
-
const mappedOrder =
|
|
903
|
+
const mappedOrder = MEDUSA_SORT_KEY_MAP[options.sortKey] ?? null;
|
|
473
904
|
if (mappedOrder !== null) {
|
|
474
905
|
queryParams.order = mappedOrder;
|
|
475
906
|
}
|
|
@@ -516,12 +947,623 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
516
947
|
fullError: JSON.stringify(error, null, 2),
|
|
517
948
|
});
|
|
518
949
|
|
|
519
|
-
throw new ProviderAPIError(
|
|
520
|
-
`Failed to fetch products: ${error.message || 'Unknown error'}`,
|
|
521
|
-
'medusa',
|
|
522
|
-
error.status,
|
|
523
|
-
);
|
|
950
|
+
throw new ProviderAPIError(
|
|
951
|
+
`Failed to fetch products: ${error.message || 'Unknown error'}`,
|
|
952
|
+
'medusa',
|
|
953
|
+
error.status,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Canonical catalog query path (E2 task 2.2).
|
|
960
|
+
*
|
|
961
|
+
* Accepts a validated `CatalogQuery`, translates it into Medusa Store
|
|
962
|
+
* `/store/products` parameters, and returns a truthful `CatalogQueryOutcome`.
|
|
963
|
+
* The route category scope is always retained and intersected with the
|
|
964
|
+
* `categories` filter; `categories`/`collections` handles are resolved to
|
|
965
|
+
* backend IDs through the existing directed lookups (`getCategory`/
|
|
966
|
+
* `getCollection`). Any requested filter this provider cannot apply
|
|
967
|
+
* (price/`inStock`/attributes) is rejected as a typed `unsupported-query`
|
|
968
|
+
* outcome naming the fields — never a partial success and never a silent
|
|
969
|
+
* drop. Pagination is `offset = (page - 1) * pageSize`, defaulting to the
|
|
970
|
+
* canonical page size.
|
|
971
|
+
*
|
|
972
|
+
* Legacy `getProducts`/`getCategoryProducts` are unchanged; this additive
|
|
973
|
+
* method is the typed seam the runtime query bridge (E2 task 3.1) consumes.
|
|
974
|
+
*/
|
|
975
|
+
async getCatalogProducts(query: CatalogQuery): Promise<CatalogQueryOutcome> {
|
|
976
|
+
this.logger.debug('getCatalogProducts', query);
|
|
977
|
+
|
|
978
|
+
// Sort support is advertised explicitly by the adapter; an unadvertised
|
|
979
|
+
// sort is rejected before any provider call.
|
|
980
|
+
if (
|
|
981
|
+
query.sort !== undefined &&
|
|
982
|
+
!this.supportedCatalogSortKeys.includes(query.sort)
|
|
983
|
+
) {
|
|
984
|
+
return this.unsupportedCatalogOutcome(['sort']);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const effectiveCategoryHandles = resolveEffectiveCategoryHandles(query);
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
const categoryIds =
|
|
991
|
+
effectiveCategoryHandles !== undefined
|
|
992
|
+
? await this.resolveCategoryIds(effectiveCategoryHandles)
|
|
993
|
+
: undefined;
|
|
994
|
+
const collectionIds = query.collections
|
|
995
|
+
? await this.resolveCollectionIds(query.collections)
|
|
996
|
+
: undefined;
|
|
997
|
+
|
|
998
|
+
// The truthful support descriptor for this scope, proven against the real
|
|
999
|
+
// optional endpoint. It is attached to every successful result.
|
|
1000
|
+
const facetSupport = await this.getCatalogFacetSupport({
|
|
1001
|
+
query,
|
|
1002
|
+
categoryIds,
|
|
1003
|
+
collectionIds,
|
|
1004
|
+
scope: query.categoryScope,
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// Reject every requested advanced field that was not demonstrated BEFORE
|
|
1008
|
+
// any early zero-result return, so an unsupported filter is never hidden
|
|
1009
|
+
// behind an empty category intersection or an unresolved handle.
|
|
1010
|
+
const requestedAdvanced = collectRequestedAdvancedFields(query);
|
|
1011
|
+
const heldAdvanced = requestedAdvanced.filter(
|
|
1012
|
+
(field) => !facetSupport.offered.includes(advancedFieldToFacet(field)),
|
|
1013
|
+
);
|
|
1014
|
+
if (heldAdvanced.length > 0) {
|
|
1015
|
+
return this.unsupportedCatalogOutcome(heldAdvanced);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// A scope that matches nothing (explicit empty route-scope ∩ categories
|
|
1019
|
+
// intersection, or a requested category/collection that resolves to no
|
|
1020
|
+
// backend ID) is a valid zero-result query — never an unfiltered request.
|
|
1021
|
+
// Requested+offered advanced filters are included as applied because the
|
|
1022
|
+
// scope already excludes every product; they are not silently dropped.
|
|
1023
|
+
const emptyIntersection =
|
|
1024
|
+
effectiveCategoryHandles !== undefined &&
|
|
1025
|
+
effectiveCategoryHandles.length === 0;
|
|
1026
|
+
const categoryFilterEmpty =
|
|
1027
|
+
effectiveCategoryHandles !== undefined &&
|
|
1028
|
+
(categoryIds?.length ?? 0) === 0;
|
|
1029
|
+
const collectionFilterEmpty =
|
|
1030
|
+
query.collections !== undefined && (collectionIds?.length ?? 0) === 0;
|
|
1031
|
+
if (emptyIntersection || categoryFilterEmpty || collectionFilterEmpty) {
|
|
1032
|
+
return {
|
|
1033
|
+
ok: true,
|
|
1034
|
+
results: {
|
|
1035
|
+
products: [],
|
|
1036
|
+
appliedFilters: buildAppliedCatalogFilters(
|
|
1037
|
+
query,
|
|
1038
|
+
effectiveCategoryHandles,
|
|
1039
|
+
requestedAdvanced.length > 0,
|
|
1040
|
+
),
|
|
1041
|
+
facetSupport,
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// When advanced filters were requested and demonstrated, the request is
|
|
1047
|
+
// executed against the real advanced endpoint and re-validated there;
|
|
1048
|
+
// it is never served by the basic endpoint with the filters dropped.
|
|
1049
|
+
if (requestedAdvanced.length > 0) {
|
|
1050
|
+
return this.queryAdvancedCatalog(
|
|
1051
|
+
query,
|
|
1052
|
+
categoryIds,
|
|
1053
|
+
collectionIds,
|
|
1054
|
+
facetSupport,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return await this.queryBasicCatalog(
|
|
1059
|
+
query,
|
|
1060
|
+
effectiveCategoryHandles,
|
|
1061
|
+
categoryIds,
|
|
1062
|
+
collectionIds,
|
|
1063
|
+
facetSupport,
|
|
1064
|
+
);
|
|
1065
|
+
} catch (error: any) {
|
|
1066
|
+
this.logger.error('getCatalogProducts error details', {
|
|
1067
|
+
message: error.message,
|
|
1068
|
+
status: error.status,
|
|
1069
|
+
});
|
|
1070
|
+
throw new ProviderAPIError(
|
|
1071
|
+
`Failed to run catalog query: ${error.message || 'Unknown error'}`,
|
|
1072
|
+
'medusa',
|
|
1073
|
+
error.status,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Execute a basic-only canonical query against the equivalent basic endpoint
|
|
1080
|
+
* `/store/products`, reporting the filters it actually applied.
|
|
1081
|
+
*/
|
|
1082
|
+
private async queryBasicCatalog(
|
|
1083
|
+
query: CatalogQuery,
|
|
1084
|
+
effectiveCategoryHandles: string[] | undefined,
|
|
1085
|
+
categoryIds: string[] | undefined,
|
|
1086
|
+
collectionIds: string[] | undefined,
|
|
1087
|
+
facetSupport: FacetSupport,
|
|
1088
|
+
): Promise<CatalogQueryOutcome> {
|
|
1089
|
+
const pageSize = query.pageSize ?? this.catalogPageSize;
|
|
1090
|
+
const page = query.page ?? 1;
|
|
1091
|
+
const regionId = this.regionId;
|
|
1092
|
+
const baseFields =
|
|
1093
|
+
'+variants,+variants.inventory_quantity,+options,+images,+thumbnail,+tags';
|
|
1094
|
+
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
1095
|
+
|
|
1096
|
+
const queryParams: any = {
|
|
1097
|
+
limit: pageSize,
|
|
1098
|
+
offset: (page - 1) * pageSize,
|
|
1099
|
+
fields: regionId ? baseFields + pricingFields : baseFields,
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
if (query.text) {
|
|
1103
|
+
queryParams.q = query.text;
|
|
1104
|
+
}
|
|
1105
|
+
if (categoryIds && categoryIds.length > 0) {
|
|
1106
|
+
queryParams.category_id = categoryIds;
|
|
1107
|
+
}
|
|
1108
|
+
if (collectionIds && collectionIds.length > 0) {
|
|
1109
|
+
queryParams.collection_id = collectionIds;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Canonical sort → Medusa `order`; `null` omits the parameter entirely.
|
|
1113
|
+
if (query.sort) {
|
|
1114
|
+
const mappedOrder = MEDUSA_SORT_KEY_MAP[query.sort] ?? null;
|
|
1115
|
+
if (mappedOrder !== null) {
|
|
1116
|
+
queryParams.order = mappedOrder;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (regionId) {
|
|
1121
|
+
queryParams.region_id = regionId;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
1125
|
+
if (salesChannelId) {
|
|
1126
|
+
queryParams.sales_channel_id = [salesChannelId];
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const response = (await this.sdk.store.product.list(queryParams)) as any;
|
|
1130
|
+
const products = (response.products ?? []).map((p: any) =>
|
|
1131
|
+
transformProduct(p, this.currencyCode),
|
|
1132
|
+
);
|
|
1133
|
+
const count = response.count;
|
|
1134
|
+
|
|
1135
|
+
return {
|
|
1136
|
+
ok: true,
|
|
1137
|
+
results: {
|
|
1138
|
+
products,
|
|
1139
|
+
// Total is only the backend's actual count; never manufactured.
|
|
1140
|
+
...(typeof count === 'number' ? { total: count } : {}),
|
|
1141
|
+
appliedFilters: buildAppliedCatalogFilters(
|
|
1142
|
+
query,
|
|
1143
|
+
effectiveCategoryHandles,
|
|
1144
|
+
),
|
|
1145
|
+
facetSupport,
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Execute an advanced canonical query against the real optional endpoint
|
|
1152
|
+
* `/store/search`, then re-validate that the response applied every requested
|
|
1153
|
+
* filter. A response that does not confirm a requested field, or an endpoint
|
|
1154
|
+
* failure, yields a typed `unsupported-query` error — never a partial result
|
|
1155
|
+
* labelled success and never a silent fallback to the basic endpoint.
|
|
1156
|
+
*/
|
|
1157
|
+
private async queryAdvancedCatalog(
|
|
1158
|
+
query: CatalogQuery,
|
|
1159
|
+
categoryIds: string[] | undefined,
|
|
1160
|
+
collectionIds: string[] | undefined,
|
|
1161
|
+
facetSupport: FacetSupport,
|
|
1162
|
+
): Promise<CatalogQueryOutcome> {
|
|
1163
|
+
const pageSize = query.pageSize ?? this.catalogPageSize;
|
|
1164
|
+
const page = query.page ?? 1;
|
|
1165
|
+
const advancedQuery = this.buildAdvancedSearchQuery(
|
|
1166
|
+
query,
|
|
1167
|
+
categoryIds ?? [],
|
|
1168
|
+
collectionIds ?? [],
|
|
1169
|
+
);
|
|
1170
|
+
advancedQuery.limit = pageSize;
|
|
1171
|
+
advancedQuery.offset = (page - 1) * pageSize;
|
|
1172
|
+
|
|
1173
|
+
let data: any;
|
|
1174
|
+
try {
|
|
1175
|
+
// Custom route: use the SDK client so the publishable API key and headers
|
|
1176
|
+
// are applied automatically (building-storefronts `sdk-client-fetch`).
|
|
1177
|
+
data = await this.sdk.client.fetch(ADVANCED_SEARCH_ENDPOINT, {
|
|
1178
|
+
query: advancedQuery,
|
|
1179
|
+
});
|
|
1180
|
+
} catch (error: any) {
|
|
1181
|
+
this.logger.error('Advanced catalog listing failed', {
|
|
1182
|
+
message: error?.message,
|
|
1183
|
+
status: error?.status,
|
|
1184
|
+
});
|
|
1185
|
+
// Do not fall back to the basic endpoint: that would silently drop the
|
|
1186
|
+
// requested advanced filters.
|
|
1187
|
+
return this.unsupportedCatalogOutcome(
|
|
1188
|
+
collectRequestedAdvancedFields(query),
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const appliedRaw = data?.applied_filters ?? data?.appliedFilters;
|
|
1193
|
+
if (!appliedRaw || typeof appliedRaw !== 'object') {
|
|
1194
|
+
this.logger.warn('Advanced endpoint did not echo applied filters', {
|
|
1195
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1196
|
+
});
|
|
1197
|
+
return this.unsupportedCatalogOutcome(
|
|
1198
|
+
collectRequestedAdvancedFields(query),
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const unapplied = collectUnappliedAdvancedFields(
|
|
1203
|
+
appliedRaw as Record<string, unknown>,
|
|
1204
|
+
query,
|
|
1205
|
+
);
|
|
1206
|
+
if (unapplied.length > 0) {
|
|
1207
|
+
this.logger.warn('Advanced endpoint did not apply requested filters', {
|
|
1208
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1209
|
+
fields: unapplied,
|
|
1210
|
+
});
|
|
1211
|
+
return this.unsupportedCatalogOutcome(unapplied);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const products = (data.hits ?? []).map((hit: any) =>
|
|
1215
|
+
transformProduct(hit, this.currencyCode),
|
|
1216
|
+
);
|
|
1217
|
+
const total =
|
|
1218
|
+
typeof data.estimatedTotalHits === 'number'
|
|
1219
|
+
? data.estimatedTotalHits
|
|
1220
|
+
: typeof data.count === 'number'
|
|
1221
|
+
? data.count
|
|
1222
|
+
: undefined;
|
|
1223
|
+
|
|
1224
|
+
return {
|
|
1225
|
+
ok: true,
|
|
1226
|
+
results: {
|
|
1227
|
+
products,
|
|
1228
|
+
...(total !== undefined ? { total } : {}),
|
|
1229
|
+
appliedFilters: buildAppliedCatalogFilters(
|
|
1230
|
+
query,
|
|
1231
|
+
resolveEffectiveCategoryHandles(query),
|
|
1232
|
+
true,
|
|
1233
|
+
),
|
|
1234
|
+
facetSupport,
|
|
1235
|
+
},
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Resolve category handles to Medusa category IDs via directed lookups. */
|
|
1240
|
+
private async resolveCategoryIds(handles: string[]): Promise<string[]> {
|
|
1241
|
+
const ids: string[] = [];
|
|
1242
|
+
for (const handle of handles) {
|
|
1243
|
+
const category = await this.getCategory(handle);
|
|
1244
|
+
if (category) ids.push(category.id);
|
|
1245
|
+
}
|
|
1246
|
+
return ids;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Resolve collection handles to Medusa collection IDs via directed lookups. */
|
|
1250
|
+
private async resolveCollectionIds(handles: string[]): Promise<string[]> {
|
|
1251
|
+
const ids: string[] = [];
|
|
1252
|
+
for (const handle of handles) {
|
|
1253
|
+
const collection = await this.getCollection(handle);
|
|
1254
|
+
if (collection) ids.push(collection.id);
|
|
1255
|
+
}
|
|
1256
|
+
return ids;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Truthful facet-support descriptor for a scope (E2 task 2.4).
|
|
1261
|
+
*
|
|
1262
|
+
* Basic facets (`categories`, `collections`) are always offered: they are
|
|
1263
|
+
* applied by the canonical basic endpoint `/store/products`, and the
|
|
1264
|
+
* descriptor records that endpoint with the applied query and scope as their
|
|
1265
|
+
* demonstration source.
|
|
1266
|
+
*
|
|
1267
|
+
* Optional advanced facets (`price`, `inStock`, and advertised `attr.<name>`)
|
|
1268
|
+
* are offered only when the real optional `/store/search` endpoint has been
|
|
1269
|
+
* demonstrated with the applied query for the current scope AND the response
|
|
1270
|
+
* echoes the filter as applied. Otherwise they are held with the real probe
|
|
1271
|
+
* status/reason — a capability boolean or returned facet metadata is never
|
|
1272
|
+
* accepted as proof (E2 Decisions 4–5; spec "Backend support is proven against
|
|
1273
|
+
* the real endpoint, not assumed").
|
|
1274
|
+
*
|
|
1275
|
+
* The descriptor carries no counts or ranges and no invented timestamp; every
|
|
1276
|
+
* offered facet has an actual `{ endpoint, appliedQuery, scope }` source.
|
|
1277
|
+
*/
|
|
1278
|
+
async getCatalogFacetSupport(
|
|
1279
|
+
options: {
|
|
1280
|
+
query?: CatalogQuery;
|
|
1281
|
+
categoryIds?: string[];
|
|
1282
|
+
collectionIds?: string[];
|
|
1283
|
+
scope?: string;
|
|
1284
|
+
} = {},
|
|
1285
|
+
): Promise<FacetSupport> {
|
|
1286
|
+
const query = options.query ?? {};
|
|
1287
|
+
const scope = options.scope ?? query.categoryScope;
|
|
1288
|
+
const key = JSON.stringify({
|
|
1289
|
+
scope: scope ?? null,
|
|
1290
|
+
categoryIds: options.categoryIds ?? null,
|
|
1291
|
+
collectionIds: options.collectionIds ?? null,
|
|
1292
|
+
categories: query.categories ?? null,
|
|
1293
|
+
text: query.text ?? null,
|
|
1294
|
+
collections: query.collections ?? null,
|
|
1295
|
+
priceMin: query.priceMin ?? null,
|
|
1296
|
+
priceMax: query.priceMax ?? null,
|
|
1297
|
+
inStock: query.inStock ?? null,
|
|
1298
|
+
attributes: query.attributes ?? null,
|
|
1299
|
+
});
|
|
1300
|
+
const cached = this.facetSupportCache.get(key);
|
|
1301
|
+
if (cached) return cached;
|
|
1302
|
+
const pending = this.resolveCatalogFacetSupport(
|
|
1303
|
+
query,
|
|
1304
|
+
options.categoryIds,
|
|
1305
|
+
options.collectionIds,
|
|
1306
|
+
scope,
|
|
1307
|
+
);
|
|
1308
|
+
this.facetSupportCache.set(key, pending);
|
|
1309
|
+
return pending;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
private async resolveCatalogFacetSupport(
|
|
1313
|
+
query: CatalogQuery,
|
|
1314
|
+
providedCategoryIds: string[] | undefined,
|
|
1315
|
+
providedCollectionIds: string[] | undefined,
|
|
1316
|
+
scope: string | undefined,
|
|
1317
|
+
): Promise<FacetSupport> {
|
|
1318
|
+
// The advanced demonstration records the full applied filter query; the
|
|
1319
|
+
// basic demonstration records only the fields `/store/products` actually
|
|
1320
|
+
// accepts (text/scope/categories/collections). Price/stock/attributes are
|
|
1321
|
+
// never recorded as applied by an endpoint that does not accept them.
|
|
1322
|
+
const advancedDemonstratedQuery: CatalogQuery = {
|
|
1323
|
+
...(query.text !== undefined ? { text: query.text } : {}),
|
|
1324
|
+
...(query.categoryScope !== undefined
|
|
1325
|
+
? { categoryScope: query.categoryScope }
|
|
1326
|
+
: {}),
|
|
1327
|
+
...(query.categories !== undefined ? { categories: query.categories } : {}),
|
|
1328
|
+
...(query.collections !== undefined
|
|
1329
|
+
? { collections: query.collections }
|
|
1330
|
+
: {}),
|
|
1331
|
+
...(query.priceMin !== undefined ? { priceMin: query.priceMin } : {}),
|
|
1332
|
+
...(query.priceMax !== undefined ? { priceMax: query.priceMax } : {}),
|
|
1333
|
+
...(query.inStock !== undefined ? { inStock: query.inStock } : {}),
|
|
1334
|
+
...(query.attributes !== undefined
|
|
1335
|
+
? { attributes: query.attributes }
|
|
1336
|
+
: {}),
|
|
1337
|
+
};
|
|
1338
|
+
const basicDemonstratedQuery = buildBasicDemonstratedQuery(query);
|
|
1339
|
+
const scopeField = scope !== undefined ? { scope } : {};
|
|
1340
|
+
|
|
1341
|
+
const offered: CatalogFacetId[] = ['categories', 'collections'];
|
|
1342
|
+
const demonstrations: Record<string, CatalogFacetDemonstrationSource> = {
|
|
1343
|
+
categories: {
|
|
1344
|
+
endpoint: BASIC_LISTING_ENDPOINT,
|
|
1345
|
+
appliedQuery: basicDemonstratedQuery,
|
|
1346
|
+
...scopeField,
|
|
1347
|
+
},
|
|
1348
|
+
collections: {
|
|
1349
|
+
endpoint: BASIC_LISTING_ENDPOINT,
|
|
1350
|
+
appliedQuery: basicDemonstratedQuery,
|
|
1351
|
+
...scopeField,
|
|
1352
|
+
},
|
|
1353
|
+
};
|
|
1354
|
+
const held: CatalogFacetId[] = [];
|
|
1355
|
+
const heldReasons: Record<string, string> = {};
|
|
1356
|
+
|
|
1357
|
+
const categoryIds =
|
|
1358
|
+
providedCategoryIds ?? (await this.resolveScopeCategoryIds(query));
|
|
1359
|
+
const collectionIds =
|
|
1360
|
+
providedCollectionIds ??
|
|
1361
|
+
(query.collections
|
|
1362
|
+
? await this.resolveCollectionIds(query.collections)
|
|
1363
|
+
: []);
|
|
1364
|
+
const candidates: CatalogFacetId[] = ['price', 'inStock'];
|
|
1365
|
+
for (const handle of await this.resolveFilterableAttributeHandles(
|
|
1366
|
+
categoryIds,
|
|
1367
|
+
)) {
|
|
1368
|
+
const facet = `attr.${handle}` as CatalogFacetId;
|
|
1369
|
+
if (!candidates.includes(facet)) candidates.push(facet);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const probe = await this.probeAdvancedSearchSupport(
|
|
1373
|
+
query,
|
|
1374
|
+
categoryIds,
|
|
1375
|
+
collectionIds,
|
|
1376
|
+
);
|
|
1377
|
+
if (probe.available) {
|
|
1378
|
+
for (const facet of candidates) {
|
|
1379
|
+
if (probe.applied.has(facet)) {
|
|
1380
|
+
offered.push(facet);
|
|
1381
|
+
demonstrations[facet] = {
|
|
1382
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1383
|
+
appliedQuery: advancedDemonstratedQuery,
|
|
1384
|
+
...scopeField,
|
|
1385
|
+
};
|
|
1386
|
+
} else {
|
|
1387
|
+
held.push(facet);
|
|
1388
|
+
heldReasons[facet] =
|
|
1389
|
+
`not echoed as applied by ${ADVANCED_SEARCH_ENDPOINT}`;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
} else {
|
|
1393
|
+
for (const facet of candidates) {
|
|
1394
|
+
held.push(facet);
|
|
1395
|
+
heldReasons[facet] = probe.reason;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
return {
|
|
1400
|
+
offered,
|
|
1401
|
+
held,
|
|
1402
|
+
demonstrations,
|
|
1403
|
+
...(Object.keys(heldReasons).length > 0 ? { heldReasons } : {}),
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Demonstrate the optional advanced endpoint with the applied query for the
|
|
1409
|
+
* current scope. A non-2xx response (including the HTTP 404/500 an absent
|
|
1410
|
+
* plugin returns), an unparseable body, or a response that does not echo the
|
|
1411
|
+
* applied filters all mean the facet is not demonstrated — never a silent
|
|
1412
|
+
* assumption of support. Uses a single SDK request (no retry): a support probe
|
|
1413
|
+
* must not amplify a broken optional endpoint into repeated requests.
|
|
1414
|
+
*/
|
|
1415
|
+
private async probeAdvancedSearchSupport(
|
|
1416
|
+
query: CatalogQuery,
|
|
1417
|
+
categoryIds: string[],
|
|
1418
|
+
collectionIds: string[],
|
|
1419
|
+
): Promise<{
|
|
1420
|
+
available: boolean;
|
|
1421
|
+
applied: Set<CatalogFacetId>;
|
|
1422
|
+
reason: string;
|
|
1423
|
+
status?: number;
|
|
1424
|
+
}> {
|
|
1425
|
+
const advancedQuery = this.buildAdvancedSearchQuery(
|
|
1426
|
+
query,
|
|
1427
|
+
categoryIds,
|
|
1428
|
+
collectionIds,
|
|
1429
|
+
);
|
|
1430
|
+
advancedQuery.limit = 1;
|
|
1431
|
+
try {
|
|
1432
|
+
// Custom route: use the SDK client so the publishable API key and headers
|
|
1433
|
+
// are applied automatically (building-storefronts `sdk-client-fetch`).
|
|
1434
|
+
const data: any = await this.sdk.client.fetch(ADVANCED_SEARCH_ENDPOINT, {
|
|
1435
|
+
query: advancedQuery,
|
|
1436
|
+
});
|
|
1437
|
+
const appliedRaw = data?.applied_filters ?? data?.appliedFilters;
|
|
1438
|
+
if (!appliedRaw || typeof appliedRaw !== 'object') {
|
|
1439
|
+
return {
|
|
1440
|
+
available: false,
|
|
1441
|
+
applied: new Set(),
|
|
1442
|
+
reason: `${ADVANCED_SEARCH_ENDPOINT} did not echo applied filters; support unverified`,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
available: true,
|
|
1447
|
+
applied: mapAppliedFiltersToFacets(
|
|
1448
|
+
appliedRaw as Record<string, unknown>,
|
|
1449
|
+
),
|
|
1450
|
+
reason: `${ADVANCED_SEARCH_ENDPOINT} demonstrated with the applied query`,
|
|
1451
|
+
};
|
|
1452
|
+
} catch (error: any) {
|
|
1453
|
+
const status =
|
|
1454
|
+
typeof error?.status === 'number' ? error.status : undefined;
|
|
1455
|
+
// Surface the absence/log it; never swallow it silently.
|
|
1456
|
+
this.logger.warn('Optional advanced search endpoint not demonstrated', {
|
|
1457
|
+
endpoint: ADVANCED_SEARCH_ENDPOINT,
|
|
1458
|
+
status,
|
|
1459
|
+
message: error?.message,
|
|
1460
|
+
});
|
|
1461
|
+
return {
|
|
1462
|
+
available: false,
|
|
1463
|
+
applied: new Set(),
|
|
1464
|
+
reason:
|
|
1465
|
+
status !== undefined
|
|
1466
|
+
? `${ADVANCED_SEARCH_ENDPOINT} returned ${status}; optional advanced endpoint not demonstrated`
|
|
1467
|
+
: `${ADVANCED_SEARCH_ENDPOINT} unavailable: ${extractErrorMessage(error)}`,
|
|
1468
|
+
...(status !== undefined ? { status } : {}),
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Build the advanced-endpoint query parameters from the canonical query. The
|
|
1475
|
+
* SDK serializes the plain object, so no manual stringification is needed.
|
|
1476
|
+
*/
|
|
1477
|
+
private buildAdvancedSearchQuery(
|
|
1478
|
+
query: CatalogQuery,
|
|
1479
|
+
categoryIds: string[],
|
|
1480
|
+
collectionIds: string[],
|
|
1481
|
+
): Record<string, unknown> {
|
|
1482
|
+
const advancedQuery: Record<string, unknown> = {};
|
|
1483
|
+
if (query.text) advancedQuery.q = query.text;
|
|
1484
|
+
if (categoryIds.length > 0) advancedQuery.categories = categoryIds.join(',');
|
|
1485
|
+
if (collectionIds.length > 0) {
|
|
1486
|
+
advancedQuery.collections = collectionIds.join(',');
|
|
1487
|
+
}
|
|
1488
|
+
if (query.priceMin !== undefined) advancedQuery.price_min = query.priceMin;
|
|
1489
|
+
if (query.priceMax !== undefined) advancedQuery.price_max = query.priceMax;
|
|
1490
|
+
if (query.inStock === true) advancedQuery.isAvailable = true;
|
|
1491
|
+
if (query.attributes) {
|
|
1492
|
+
// The backend route declares `attributes: z.string()` and JSON-parses an
|
|
1493
|
+
// ARRAY of `{ id, values }`. The canonical query carries a
|
|
1494
|
+
// `Record<string, string[]>`, so map it before serializing.
|
|
1495
|
+
const attributes = Object.entries(query.attributes).map(([id, values]) => ({
|
|
1496
|
+
id,
|
|
1497
|
+
values,
|
|
1498
|
+
}));
|
|
1499
|
+
advancedQuery.attributes = JSON.stringify(attributes);
|
|
1500
|
+
}
|
|
1501
|
+
if (query.sort) {
|
|
1502
|
+
const mappedOrder = MEDUSA_SORT_KEY_MAP[query.sort] ?? null;
|
|
1503
|
+
if (mappedOrder !== null) advancedQuery.order = mappedOrder;
|
|
1504
|
+
}
|
|
1505
|
+
if (this.regionId) advancedQuery.region_id = this.regionId;
|
|
1506
|
+
if (this.currencyCode) {
|
|
1507
|
+
advancedQuery.currency_code = this.currencyCode.toLowerCase();
|
|
1508
|
+
}
|
|
1509
|
+
return advancedQuery;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** Resolve the effective category scope of a query to backend IDs. */
|
|
1513
|
+
private async resolveScopeCategoryIds(query: CatalogQuery): Promise<string[]> {
|
|
1514
|
+
const handles = resolveEffectiveCategoryHandles(query);
|
|
1515
|
+
if (handles === undefined || handles.length === 0) return [];
|
|
1516
|
+
return this.resolveCategoryIds(handles);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Advertised filterable attribute handles for the scope. The custom-attributes
|
|
1521
|
+
* endpoint is facet *metadata* (which attributes exist), not proof that a
|
|
1522
|
+
* filter can be applied; these handles are candidates only, and each still
|
|
1523
|
+
* requires the advanced endpoint to echo it as applied before it is offered.
|
|
1524
|
+
*/
|
|
1525
|
+
private async resolveFilterableAttributeHandles(
|
|
1526
|
+
categoryIds: string[],
|
|
1527
|
+
): Promise<string[]> {
|
|
1528
|
+
const handles: string[] = [];
|
|
1529
|
+
for (const categoryId of categoryIds) {
|
|
1530
|
+
try {
|
|
1531
|
+
// Custom route: use the SDK client so the publishable API key and
|
|
1532
|
+
// headers are applied automatically (building-storefronts
|
|
1533
|
+
// `sdk-client-fetch`).
|
|
1534
|
+
const data: any = await this.sdk.client.fetch(
|
|
1535
|
+
`/store/custom-attributes/category/${categoryId}`,
|
|
1536
|
+
);
|
|
1537
|
+
for (const item of data?.attributes ?? []) {
|
|
1538
|
+
const attr = item.attribute ?? item;
|
|
1539
|
+
if (
|
|
1540
|
+
attr?.is_filterable &&
|
|
1541
|
+
attr?.is_active &&
|
|
1542
|
+
typeof attr.handle === 'string' &&
|
|
1543
|
+
attr.handle.length > 0
|
|
1544
|
+
) {
|
|
1545
|
+
handles.push(attr.handle);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
} catch {
|
|
1549
|
+
// The optional custom-attributes endpoint may be absent; its absence
|
|
1550
|
+
// simply means no advertised attribute facets for the scope.
|
|
1551
|
+
}
|
|
524
1552
|
}
|
|
1553
|
+
return handles;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Build a typed `unsupported-query` outcome naming the offending fields. */
|
|
1557
|
+
private unsupportedCatalogOutcome(fields: string[]): CatalogQueryOutcome {
|
|
1558
|
+
return {
|
|
1559
|
+
ok: false,
|
|
1560
|
+
error: {
|
|
1561
|
+
code: 'unsupported-query',
|
|
1562
|
+
fields,
|
|
1563
|
+
message: `Unsupported catalog query: ${fields.join(', ')}`,
|
|
1564
|
+
repair: { remove: fields },
|
|
1565
|
+
},
|
|
1566
|
+
};
|
|
525
1567
|
}
|
|
526
1568
|
|
|
527
1569
|
async getProductRecommendations(productId: string): Promise<Product[]> {
|
|
@@ -555,18 +1597,14 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
555
1597
|
}> {
|
|
556
1598
|
this.logger.debug('searchProductsAdvanced', options);
|
|
557
1599
|
|
|
558
|
-
// Check if advanced search is available
|
|
1600
|
+
// Check if advanced search is available. A capability boolean is not proof,
|
|
1601
|
+
// but when it is false the optional endpoint is definitely not wired; a
|
|
1602
|
+
// basic-only caller still gets the faithful `getProducts` fallback while a
|
|
1603
|
+
// caller that requested an advanced filter is rejected instead of silently
|
|
1604
|
+
// receiving unfiltered products (E2 task 2.4).
|
|
559
1605
|
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
|
-
};
|
|
1606
|
+
this.logger.warn('Advanced search capability disabled, using faithful fallback');
|
|
1607
|
+
return this.fallbackAdvancedSearch(options);
|
|
570
1608
|
}
|
|
571
1609
|
|
|
572
1610
|
try {
|
|
@@ -704,22 +1742,49 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
704
1742
|
|
|
705
1743
|
return { products, meta };
|
|
706
1744
|
} catch (error: any) {
|
|
707
|
-
this.logger.error('Advanced search failed,
|
|
1745
|
+
this.logger.error('Advanced search failed, using faithful fallback', {
|
|
708
1746
|
error: error.message,
|
|
709
1747
|
stack: error.stack,
|
|
710
1748
|
});
|
|
711
1749
|
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1750
|
+
// Do not silently drop advanced filters: a caller that requested price,
|
|
1751
|
+
// stock, or attributes gets a typed failure; a basic-only caller keeps the
|
|
1752
|
+
// faithful `getProducts` fallback.
|
|
1753
|
+
return this.fallbackAdvancedSearch(options);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* Faithful fallback for `searchProductsAdvanced`. A basic-only request is
|
|
1759
|
+
* served by `getProducts`; a request that depends on the optional advanced
|
|
1760
|
+
* endpoint is rejected as a typed `ProviderAPIError` naming the unappliable
|
|
1761
|
+
* fields, so a failed advanced search never returns an unfiltered result
|
|
1762
|
+
* labelled success (E2 task 2.4).
|
|
1763
|
+
*/
|
|
1764
|
+
private async fallbackAdvancedSearch(
|
|
1765
|
+
options?: AdvancedSearchProductsOptions,
|
|
1766
|
+
): Promise<{
|
|
1767
|
+
products: Product[];
|
|
1768
|
+
meta?: SearchResultMeta;
|
|
1769
|
+
}> {
|
|
1770
|
+
const requested = collectAdvancedSearchRequestFields(options);
|
|
1771
|
+
if (requested.length > 0) {
|
|
1772
|
+
throw new ProviderAPIError(
|
|
1773
|
+
`Advanced search unavailable for requested fields: ${requested.join(', ')}`,
|
|
1774
|
+
'medusa',
|
|
1775
|
+
undefined,
|
|
1776
|
+
{ fields: requested, repair: { remove: requested } },
|
|
1777
|
+
);
|
|
722
1778
|
}
|
|
1779
|
+
const products = await this.getProducts(options);
|
|
1780
|
+
return {
|
|
1781
|
+
products,
|
|
1782
|
+
meta: {
|
|
1783
|
+
limit: options?.first ?? 20,
|
|
1784
|
+
offset: Number(options?.after ?? 0),
|
|
1785
|
+
hybridSearchUsed: false,
|
|
1786
|
+
},
|
|
1787
|
+
};
|
|
723
1788
|
}
|
|
724
1789
|
|
|
725
1790
|
/**
|
|
@@ -780,29 +1845,38 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
780
1845
|
}
|
|
781
1846
|
}
|
|
782
1847
|
|
|
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,
|
|
1848
|
+
// 2. Advanced filters (stock)
|
|
1849
|
+
//
|
|
1850
|
+
// A price range is emitted ONLY when the backend supplies real range
|
|
1851
|
+
// metadata for the current query. Medusa's Store `/store/products`
|
|
1852
|
+
// returns the matching products and a total count, not the filterable
|
|
1853
|
+
// values or their numeric ranges (Store API contract; facets require a
|
|
1854
|
+
// separate search plugin), so there is no price range to read here and
|
|
1855
|
+
// the control is omitted instead of defaulted to a fabricated
|
|
1856
|
+
// `0..10000` (E2 Decision 4; spec "Facet counts and ranges are never
|
|
1857
|
+
// fabricated").
|
|
1858
|
+
//
|
|
1859
|
+
// The stock toggle is offered ONLY when the real optional endpoint has
|
|
1860
|
+
// demonstrated `inStock` for the scope. A capability boolean is not proof
|
|
1861
|
+
// (E2 task 2.4): the backend advertises `advancedSearch: true` while the
|
|
1862
|
+
// optional `/store/search` endpoint is absent, so the toggle is held
|
|
1863
|
+
// rather than shown.
|
|
1864
|
+
if (options.useAdvancedSearch) {
|
|
1865
|
+
const support = await this.getCatalogFacetSupport({
|
|
1866
|
+
categoryIds: options.categoryId ? [options.categoryId] : [],
|
|
1867
|
+
scope: options.categoryId,
|
|
805
1868
|
});
|
|
1869
|
+
if (support.offered.includes('inStock')) {
|
|
1870
|
+
// Stock availability filter
|
|
1871
|
+
// ID must match the URL param read by page.tsx (?isAvailable=true)
|
|
1872
|
+
filters.push({
|
|
1873
|
+
id: 'isAvailable',
|
|
1874
|
+
label: 'In Stock',
|
|
1875
|
+
type: 'boolean',
|
|
1876
|
+
isActive: false,
|
|
1877
|
+
ranking: 5,
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
806
1880
|
}
|
|
807
1881
|
|
|
808
1882
|
// 3. Custom attributes (category-specific)
|
|
@@ -852,20 +1926,37 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
852
1926
|
ranking: attr.ranking || 50, // Custom attributes lower priority
|
|
853
1927
|
};
|
|
854
1928
|
|
|
855
|
-
// Add options for single/multi types
|
|
1929
|
+
// Add options for single/multi types. A per-option count is
|
|
1930
|
+
// attached only when the caller asked for counts AND the backend
|
|
1931
|
+
// supplied a real finite number; no zero or fabricated count is
|
|
1932
|
+
// invented (E2 Decision 4; spec "Facet counts and ranges are
|
|
1933
|
+
// never fabricated").
|
|
856
1934
|
if ((filterType === 'single' || filterType === 'multi') && attr.values) {
|
|
857
|
-
filter.options = attr.values.map((val: any) =>
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1935
|
+
filter.options = attr.values.map((val: any) => {
|
|
1936
|
+
const count = options.includeFacetCounts
|
|
1937
|
+
? readFiniteFacetNumber(val.count)
|
|
1938
|
+
: undefined;
|
|
1939
|
+
return {
|
|
1940
|
+
value: this.toSlug(val.value), // URL slug: ?ram=8gb
|
|
1941
|
+
label: val.value, // Display: "8GB"
|
|
1942
|
+
id: val.id, // Backend ID: 01KN03N67V13K5S2E6B6695KRN
|
|
1943
|
+
...(count !== undefined ? { count } : {}),
|
|
1944
|
+
};
|
|
1945
|
+
});
|
|
863
1946
|
}
|
|
864
1947
|
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1948
|
+
// A numeric range is emitted only when the backend supplies BOTH
|
|
1949
|
+
// real bounds. A missing bound is never replaced by a fabricated
|
|
1950
|
+
// `0`/`100` default; without a real range the control is omitted
|
|
1951
|
+
// rather than shown with invented values (E2 Decision 4).
|
|
1952
|
+
if (filterType === 'range') {
|
|
1953
|
+
const min = readFiniteFacetNumber(attr.metadata?.min);
|
|
1954
|
+
const max = readFiniteFacetNumber(attr.metadata?.max);
|
|
1955
|
+
if (min === undefined || max === undefined) {
|
|
1956
|
+
return; // no real range → omit this range filter
|
|
1957
|
+
}
|
|
1958
|
+
filter.min = min;
|
|
1959
|
+
filter.max = max;
|
|
869
1960
|
}
|
|
870
1961
|
|
|
871
1962
|
filters.push(filter);
|
|
@@ -1311,9 +2402,9 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1311
2402
|
}
|
|
1312
2403
|
|
|
1313
2404
|
try {
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
const limit = options.
|
|
2405
|
+
// Bounded per-request limit (change B, decision 5): no unbounded scan.
|
|
2406
|
+
// Client-side search filtering still runs over the bounded page.
|
|
2407
|
+
const limit = options.limit ?? this.catalogPageSize;
|
|
1317
2408
|
|
|
1318
2409
|
const queryParams: any = {
|
|
1319
2410
|
limit,
|
|
@@ -1376,13 +2467,24 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1376
2467
|
// Then get products for this category
|
|
1377
2468
|
const categoryQueryParams: any = {
|
|
1378
2469
|
category_id: [category.id],
|
|
1379
|
-
|
|
1380
|
-
limit: options.first ??
|
|
2470
|
+
// Bounded, paginated listing (change B, decision 5).
|
|
2471
|
+
limit: options.first ?? this.catalogPageSize,
|
|
1381
2472
|
offset: options.after ? parseInt(options.after, 10) : undefined,
|
|
1382
2473
|
fields:
|
|
1383
|
-
'+variants,+variants.prices,+variants.
|
|
2474
|
+
'+variants,+variants.prices,+variants.calculated_price,+variants.inventory_quantity,+options,+images,+tags',
|
|
1384
2475
|
};
|
|
1385
2476
|
|
|
2477
|
+
// Translate the Thor sort key through the same mapping `getProducts` uses.
|
|
2478
|
+
// A raw canonical key (`name`/`newest`/`featured`) is not a valid Medusa
|
|
2479
|
+
// `order` field and would be rejected by the Store API; `null` omits
|
|
2480
|
+
// `order` so the backend returns its default ordering.
|
|
2481
|
+
const mappedOrder = options.sortKey
|
|
2482
|
+
? MEDUSA_SORT_KEY_MAP[options.sortKey] ?? null
|
|
2483
|
+
: null;
|
|
2484
|
+
if (mappedOrder !== null) {
|
|
2485
|
+
categoryQueryParams.order = mappedOrder;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
1386
2488
|
// Region-based pricing (calculated_price requires region_id in Medusa v2)
|
|
1387
2489
|
if (this.regionId) {
|
|
1388
2490
|
categoryQueryParams.region_id = this.regionId;
|
|
@@ -1436,7 +2538,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1436
2538
|
// passing it explicitly is required when the key maps to multiple
|
|
1437
2539
|
// channels (otherwise cart creation errors).
|
|
1438
2540
|
const salesChannelId =
|
|
1439
|
-
options?.salesChannelId ??
|
|
2541
|
+
options?.salesChannelId ?? resolveConfiguredSalesChannelId();
|
|
1440
2542
|
if (salesChannelId) {
|
|
1441
2543
|
createPayload.sales_channel_id = salesChannelId;
|
|
1442
2544
|
}
|
|
@@ -1529,7 +2631,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1529
2631
|
};
|
|
1530
2632
|
|
|
1531
2633
|
// Scope to storefront sales channel when configured.
|
|
1532
|
-
const salesChannelId =
|
|
2634
|
+
const salesChannelId = resolveConfiguredSalesChannelId();
|
|
1533
2635
|
if (salesChannelId) {
|
|
1534
2636
|
variantCheckParams.sales_channel_id = [salesChannelId];
|
|
1535
2637
|
}
|
|
@@ -2925,36 +4027,96 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
2925
4027
|
* @throws {ProviderAPIError} If checkout fails
|
|
2926
4028
|
*/
|
|
2927
4029
|
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
|
-
}
|
|
4030
|
+
const result = await this.completeCart(cartId);
|
|
2939
4031
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
});
|
|
4032
|
+
if (result.type === 'order') {
|
|
4033
|
+
return result.order;
|
|
4034
|
+
}
|
|
2944
4035
|
|
|
2945
|
-
|
|
2946
|
-
},
|
|
2947
|
-
'completeCheckout',
|
|
2948
|
-
{ cartId },
|
|
2949
|
-
);
|
|
2950
|
-
} catch (error: any) {
|
|
2951
|
-
this.logger.error('Failed to complete checkout', { cartId, error: error.message });
|
|
4036
|
+
if (result.type === 'cart') {
|
|
2952
4037
|
throw new ProviderAPIError(
|
|
2953
|
-
`Failed to complete checkout: ${error.message}`,
|
|
4038
|
+
`Failed to complete checkout: ${result.error.message}`,
|
|
2954
4039
|
'medusa',
|
|
2955
|
-
error.status,
|
|
4040
|
+
result.error.status,
|
|
2956
4041
|
);
|
|
2957
4042
|
}
|
|
4043
|
+
|
|
4044
|
+
throw new ProviderAPIError(
|
|
4045
|
+
`Checkout outcome is unresolved: ${result.error.message}`,
|
|
4046
|
+
'medusa',
|
|
4047
|
+
result.error.status,
|
|
4048
|
+
);
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
/**
|
|
4052
|
+
* Complete a cart exactly once and preserve the native discriminator
|
|
4053
|
+
* (change B, decision 11).
|
|
4054
|
+
*
|
|
4055
|
+
* Medusa's `POST /store/carts/:id/complete` returns HTTP 200 whose body is
|
|
4056
|
+
* either `{ type: 'order', order }` or `{ type: 'cart', cart, error }`. This
|
|
4057
|
+
* method never retries: a thrown transport/timeout/5xx failure becomes an
|
|
4058
|
+
* `unresolved` result so the caller reconciles the committed cart before any
|
|
4059
|
+
* further attempt. The Medusa JS SDK performs no implicit retry of its own,
|
|
4060
|
+
* so there is no blind retry beneath this call either.
|
|
4061
|
+
*/
|
|
4062
|
+
async completeCart(cartId: string): Promise<CartCompletionResult> {
|
|
4063
|
+
this.logger.debug('completeCart', { cartId });
|
|
4064
|
+
|
|
4065
|
+
try {
|
|
4066
|
+
const response = (await this.sdk.store.cart.complete(cartId, {
|
|
4067
|
+
fields: MEDUSA_ORDER_COMPLETION_FIELDS,
|
|
4068
|
+
} as any)) as any;
|
|
4069
|
+
|
|
4070
|
+
if (response?.type === 'cart' || (response?.cart && response?.error)) {
|
|
4071
|
+
const error = this.toCompletionError(response.error);
|
|
4072
|
+
this.logger.warn('Cart completion returned a cart with an error', {
|
|
4073
|
+
cartId,
|
|
4074
|
+
code: error.code,
|
|
4075
|
+
message: error.message,
|
|
4076
|
+
});
|
|
4077
|
+
return { type: 'cart', cart: transformCart(response.cart), error };
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
const order = response?.order;
|
|
4081
|
+
if (!order) {
|
|
4082
|
+
return {
|
|
4083
|
+
type: 'unresolved',
|
|
4084
|
+
error: { message: 'Checkout returned neither an order nor a cart error' },
|
|
4085
|
+
};
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
this.logger.info('Checkout completed successfully', {
|
|
4089
|
+
orderId: order.id,
|
|
4090
|
+
displayId: order.display_id,
|
|
4091
|
+
});
|
|
4092
|
+
return { type: 'order', order: transformOrder(order) };
|
|
4093
|
+
} catch (error: unknown) {
|
|
4094
|
+
const completionError = this.toCompletionError(error);
|
|
4095
|
+
this.logger.error('Cart completion failed (unresolved, not retried)', {
|
|
4096
|
+
cartId,
|
|
4097
|
+
status: completionError.status,
|
|
4098
|
+
message: completionError.message,
|
|
4099
|
+
});
|
|
4100
|
+
return { type: 'unresolved', error: completionError };
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
/** Normalize any thrown/returned completion failure into a structured error. */
|
|
4105
|
+
private toCompletionError(error: unknown): CartCompletionError {
|
|
4106
|
+
if (error && typeof error === 'object') {
|
|
4107
|
+
const err = error as any;
|
|
4108
|
+
const message =
|
|
4109
|
+
typeof err.message === 'string' && err.message.length > 0
|
|
4110
|
+
? err.message
|
|
4111
|
+
: extractErrorMessage(error);
|
|
4112
|
+
return {
|
|
4113
|
+
message,
|
|
4114
|
+
code: typeof err.code === 'string' ? err.code : undefined,
|
|
4115
|
+
status: err.status ?? err.statusCode,
|
|
4116
|
+
raw: err,
|
|
4117
|
+
};
|
|
4118
|
+
}
|
|
4119
|
+
return { message: extractErrorMessage(error), raw: error };
|
|
2958
4120
|
}
|
|
2959
4121
|
|
|
2960
4122
|
/**
|
|
@@ -2967,160 +4129,90 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
2967
4129
|
* @throws {ProviderAPIError} If cart not found or region not configured
|
|
2968
4130
|
*/
|
|
2969
4131
|
async getShippingMethods(cartId: string): Promise<import('@thorprovider/types').ShippingMethod[]> {
|
|
2970
|
-
this.logger.
|
|
4132
|
+
this.logger.debug('getShippingMethods', { cartId });
|
|
2971
4133
|
|
|
2972
4134
|
try {
|
|
2973
4135
|
return await this.withRetry(
|
|
2974
4136
|
async () => {
|
|
2975
|
-
//
|
|
2976
|
-
this.logger.info('🚚 [DEBUG] Step 1: Retrieving cart from Medusa...', { cartId });
|
|
4137
|
+
// The cart supplies the region and currency the options are priced in.
|
|
2977
4138
|
const cartResponse = (await this.sdk.store.cart.retrieve(cartId)) as any;
|
|
2978
4139
|
const { cart } = cartResponse;
|
|
2979
4140
|
|
|
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
4141
|
if (!cart) {
|
|
2996
|
-
this.logger.error('🚚 [DEBUG] ERROR: Cart not found in response', {
|
|
2997
|
-
cartId,
|
|
2998
|
-
cartResponse,
|
|
2999
|
-
});
|
|
3000
4142
|
throw new ResourceNotFoundError('Cart', cartId);
|
|
3001
4143
|
}
|
|
3002
4144
|
|
|
3003
4145
|
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
4146
|
throw new ProviderAPIError('Cart has no region assigned', 'medusa', 400);
|
|
3014
4147
|
}
|
|
3015
4148
|
|
|
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
|
-
}
|
|
4149
|
+
// Medusa v2: list the shipping options eligible for this cart. The
|
|
4150
|
+
// JS SDK exposes `store.fulfillment.listCartOptions`; fall back to the
|
|
4151
|
+
// equivalent HTTP route for SDK builds that do not.
|
|
4152
|
+
let shippingOptions: any[] = [];
|
|
4153
|
+
const storeAny = this.sdk.store as any;
|
|
4154
|
+
|
|
4155
|
+
if (storeAny?.fulfillment?.listCartOptions) {
|
|
4156
|
+
const response = (await storeAny.fulfillment.listCartOptions({
|
|
4157
|
+
cart_id: cartId,
|
|
4158
|
+
})) as any;
|
|
4159
|
+
shippingOptions = response?.shipping_options ?? [];
|
|
4160
|
+
} else {
|
|
4161
|
+
const baseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
|
|
4162
|
+
const url = new URL('store/shipping-options', baseUrl);
|
|
4163
|
+
url.searchParams.set('cart_id', cartId);
|
|
3056
4164
|
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
// Log detailed error from Medusa backend
|
|
3060
|
-
this.logger.error(
|
|
3061
|
-
'🚚 [DEBUG] ERROR: Medusa SDK threw error when fetching shipping options',
|
|
4165
|
+
const response = await fetchWithRetry(
|
|
4166
|
+
url.toString(),
|
|
3062
4167
|
{
|
|
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),
|
|
4168
|
+
method: 'GET',
|
|
4169
|
+
headers: {
|
|
4170
|
+
'x-publishable-api-key': this.publishableKey,
|
|
4171
|
+
'content-type': 'application/json',
|
|
4172
|
+
},
|
|
3074
4173
|
},
|
|
4174
|
+
this.retryConfig,
|
|
3075
4175
|
);
|
|
3076
|
-
|
|
4176
|
+
|
|
4177
|
+
shippingOptions = ((await response.json()) as any)?.shipping_options ?? [];
|
|
3077
4178
|
}
|
|
3078
4179
|
|
|
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
|
-
});
|
|
4180
|
+
const currencyCode = cart.currency_code?.toUpperCase() ?? '';
|
|
3095
4181
|
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
4182
|
+
// Truthful projection (change B, decision 17): the amount is the
|
|
4183
|
+
// backend's option amount (or its tax-inclusive amount) and is never
|
|
4184
|
+
// replaced with a fabricated `'0'`; an absent description/ETA is
|
|
4185
|
+
// omitted rather than filled with a placeholder like `'N/A'`.
|
|
4186
|
+
return shippingOptions.map((option: any) => {
|
|
4187
|
+
const amount = option?.amount ?? option?.price_incl_tax;
|
|
4188
|
+
const estimatedDays =
|
|
4189
|
+
option?.data?.estimated_days != null
|
|
4190
|
+
? String(option.data.estimated_days)
|
|
4191
|
+
: undefined;
|
|
3106
4192
|
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
4193
|
+
return {
|
|
4194
|
+
id: option.id,
|
|
4195
|
+
name: option.name,
|
|
4196
|
+
price: {
|
|
4197
|
+
amount:
|
|
4198
|
+
typeof amount === 'number' && Number.isFinite(amount)
|
|
4199
|
+
? amount.toFixed(2)
|
|
4200
|
+
: '',
|
|
4201
|
+
currencyCode,
|
|
4202
|
+
},
|
|
4203
|
+
description: option?.data?.description,
|
|
4204
|
+
estimatedDays,
|
|
4205
|
+
fulfillmentSetType: option?.service_zone?.fulfillment_set?.type ?? null,
|
|
4206
|
+
};
|
|
3112
4207
|
});
|
|
3113
|
-
|
|
3114
|
-
return methods;
|
|
3115
4208
|
},
|
|
3116
4209
|
'getShippingMethods',
|
|
3117
4210
|
{ cartId },
|
|
3118
4211
|
);
|
|
3119
4212
|
} catch (error: any) {
|
|
3120
|
-
this.logger.error('
|
|
4213
|
+
this.logger.error('Failed to fetch shipping methods', {
|
|
3121
4214
|
cartId,
|
|
3122
4215
|
errorMessage: error.message,
|
|
3123
|
-
errorStack: error.stack,
|
|
3124
4216
|
errorStatus: error.status,
|
|
3125
4217
|
});
|
|
3126
4218
|
throw new ProviderAPIError(
|
|
@@ -3571,22 +4663,19 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3571
4663
|
this.logger.debug('addShippingMethod', { cartId, methodId });
|
|
3572
4664
|
|
|
3573
4665
|
try {
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
'addShippingMethod',
|
|
3588
|
-
{ cartId, methodId },
|
|
3589
|
-
);
|
|
4666
|
+
// Medusa v2: POST /store/carts/:id/shipping-methods.
|
|
4667
|
+
// This is a non-idempotent mutation, so it is sent exactly once and is
|
|
4668
|
+
// NOT wrapped in a retry (change B, decision 11).
|
|
4669
|
+
await this.sdk.store.cart.addShippingMethod(cartId, {
|
|
4670
|
+
option_id: methodId,
|
|
4671
|
+
} as any);
|
|
4672
|
+
|
|
4673
|
+
this.logger.info('Shipping method added successfully', { cartId, methodId });
|
|
4674
|
+
|
|
4675
|
+
// Re-read the authoritative cart so the recalculated totals come from
|
|
4676
|
+
// the backend rather than the mutation response.
|
|
4677
|
+
const updatedCart = await this.retrieveCartWithLineDetails(cartId);
|
|
4678
|
+
return transformCart(updatedCart);
|
|
3590
4679
|
} catch (error: any) {
|
|
3591
4680
|
this.logger.error('Failed to add shipping method', {
|
|
3592
4681
|
cartId,
|
|
@@ -3611,199 +4700,98 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3611
4700
|
* @throws {ProviderAPIError} If provider invalid or cart not ready
|
|
3612
4701
|
*/
|
|
3613
4702
|
async initiatePaymentSession(cartId: string, providerId: string): Promise<void> {
|
|
3614
|
-
this.logger.
|
|
4703
|
+
this.logger.debug('initiatePaymentSession', { cartId, providerId });
|
|
3615
4704
|
|
|
3616
4705
|
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 || [];
|
|
4706
|
+
// Read the raw cart: the canonical transform does not carry the payment
|
|
4707
|
+
// collection, and the session compatibility check needs the backend's own
|
|
4708
|
+
// session shape (provider, amount, status).
|
|
4709
|
+
const { cart: rawCart } = await this.sdk.store.cart.retrieve(cartId, {
|
|
4710
|
+
fields:
|
|
4711
|
+
'+shipping_methods,+shipping_address,+email,+region_id,+total,+items,+payment_collection,+payment_collection.payment_sessions,+billing_address',
|
|
4712
|
+
});
|
|
3703
4713
|
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
4714
|
+
if (!rawCart.items || rawCart.items.length === 0) {
|
|
4715
|
+
throw new ProviderAPIError(
|
|
4716
|
+
'Cannot create payment session: cart is empty. Please add items to your cart before checking out.',
|
|
4717
|
+
'medusa',
|
|
4718
|
+
400,
|
|
4719
|
+
);
|
|
4720
|
+
}
|
|
3710
4721
|
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
4722
|
+
if (!rawCart.shipping_address) {
|
|
4723
|
+
throw new ProviderAPIError(
|
|
4724
|
+
'Shipping address is required before initiating payment session',
|
|
4725
|
+
'medusa',
|
|
4726
|
+
400,
|
|
4727
|
+
);
|
|
4728
|
+
}
|
|
3714
4729
|
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
4730
|
+
// Payment is applied only after shipping totals are final (decision 10).
|
|
4731
|
+
if (!rawCart.shipping_methods?.length) {
|
|
4732
|
+
throw new ProviderAPIError(
|
|
4733
|
+
'Shipping method is required before initiating payment session',
|
|
4734
|
+
'medusa',
|
|
4735
|
+
400,
|
|
4736
|
+
);
|
|
4737
|
+
}
|
|
3720
4738
|
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
4739
|
+
if (!rawCart.email) {
|
|
4740
|
+
throw new ProviderAPIError(
|
|
4741
|
+
'Email is required before initiating payment session',
|
|
4742
|
+
'medusa',
|
|
4743
|
+
400,
|
|
4744
|
+
);
|
|
4745
|
+
}
|
|
3728
4746
|
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
4747
|
+
validatePaymentId(providerId);
|
|
4748
|
+
|
|
4749
|
+
// Reuse an existing session ONLY when its provider, state and amount are
|
|
4750
|
+
// compatible with the current cart; otherwise create/replace it.
|
|
4751
|
+
const existingSessions: any[] = rawCart.payment_collection?.payment_sessions ?? [];
|
|
4752
|
+
const compatible = existingSessions.find((session: any) => {
|
|
4753
|
+
const statusOk = ['pending', 'requires_more', 'authorized'].includes(session?.status);
|
|
4754
|
+
return (
|
|
4755
|
+
session?.provider_id === providerId &&
|
|
4756
|
+
session?.amount === rawCart.total &&
|
|
4757
|
+
statusOk
|
|
4758
|
+
);
|
|
4759
|
+
});
|
|
3734
4760
|
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
throw new ProviderAPIError(
|
|
3745
|
-
validationError.message,
|
|
3746
|
-
'medusa',
|
|
3747
|
-
400
|
|
3748
|
-
);
|
|
3749
|
-
}
|
|
4761
|
+
if (compatible) {
|
|
4762
|
+
this.logger.info('Reusing compatible payment session', {
|
|
4763
|
+
cartId,
|
|
4764
|
+
providerId,
|
|
4765
|
+
sessionId: compatible.id,
|
|
4766
|
+
amount: compatible.amount,
|
|
4767
|
+
});
|
|
4768
|
+
return;
|
|
4769
|
+
}
|
|
3750
4770
|
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
4771
|
+
if (existingSessions.length > 0) {
|
|
4772
|
+
this.logger.info('Existing payment session incompatible; replacing it', {
|
|
4773
|
+
cartId,
|
|
4774
|
+
providerId,
|
|
4775
|
+
existing: existingSessions.map((session: any) => ({
|
|
4776
|
+
id: session.id,
|
|
4777
|
+
providerId: session.provider_id,
|
|
4778
|
+
amount: session.amount,
|
|
4779
|
+
status: session.status,
|
|
4780
|
+
})),
|
|
4781
|
+
});
|
|
4782
|
+
}
|
|
3756
4783
|
|
|
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
|
-
}
|
|
4784
|
+
// Non-idempotent mutation: sent exactly once, never retried.
|
|
4785
|
+
await this.sdk.store.payment.initiatePaymentSession(rawCart as any, {
|
|
4786
|
+
provider_id: providerId,
|
|
4787
|
+
});
|
|
3792
4788
|
|
|
3793
|
-
|
|
3794
|
-
cartId,
|
|
3795
|
-
providerId,
|
|
3796
|
-
});
|
|
3797
|
-
},
|
|
3798
|
-
'initiatePaymentSession',
|
|
3799
|
-
{ cartId, providerId },
|
|
3800
|
-
);
|
|
4789
|
+
this.logger.info('Payment session initiated', { cartId, providerId });
|
|
3801
4790
|
} catch (error: any) {
|
|
3802
|
-
this.logger.error('
|
|
4791
|
+
this.logger.error('Failed to initiate payment session', {
|
|
3803
4792
|
cartId,
|
|
3804
4793
|
providerId,
|
|
3805
4794
|
errorMessage: error.message,
|
|
3806
|
-
errorStack: error.stack,
|
|
3807
4795
|
errorStatus: error.status,
|
|
3808
4796
|
});
|
|
3809
4797
|
throw new ProviderAPIError(
|
|
@@ -3828,36 +4816,28 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3828
4816
|
this.logger.debug('applyDiscountCode', { cartId, code });
|
|
3829
4817
|
|
|
3830
4818
|
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
|
-
}
|
|
4819
|
+
// Medusa v2: POST /store/carts/:id/promotions.
|
|
4820
|
+
// Non-idempotent mutation: sent exactly once, never retried.
|
|
4821
|
+
const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
|
|
4822
|
+
method: 'POST',
|
|
4823
|
+
headers: {
|
|
4824
|
+
'Content-Type': 'application/json',
|
|
4825
|
+
'x-publishable-api-key': this.publishableKey || '',
|
|
4826
|
+
},
|
|
4827
|
+
body: JSON.stringify({
|
|
4828
|
+
promo_codes: [code],
|
|
4829
|
+
}),
|
|
4830
|
+
});
|
|
3849
4831
|
|
|
3850
|
-
|
|
4832
|
+
if (!response.ok) {
|
|
4833
|
+
const error = await response.json();
|
|
4834
|
+
throw new Error(error.message || 'Failed to apply discount code');
|
|
4835
|
+
}
|
|
3851
4836
|
|
|
3852
|
-
|
|
4837
|
+
this.logger.info('Discount code applied successfully', { cartId, code });
|
|
3853
4838
|
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
return transformCart(updatedCart);
|
|
3857
|
-
},
|
|
3858
|
-
'applyDiscountCode',
|
|
3859
|
-
{ cartId, code },
|
|
3860
|
-
);
|
|
4839
|
+
const updatedCart = await this.retrieveCartWithLineDetails(cartId);
|
|
4840
|
+
return transformCart(updatedCart);
|
|
3861
4841
|
} catch (error: any) {
|
|
3862
4842
|
this.logger.error('Failed to apply discount code', { cartId, code, error: error.message });
|
|
3863
4843
|
throw new ProviderAPIError(
|
|
@@ -3882,36 +4862,28 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
3882
4862
|
this.logger.debug('removeDiscountCode', { cartId, code });
|
|
3883
4863
|
|
|
3884
4864
|
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
|
-
}
|
|
4865
|
+
// Medusa v2: DELETE /store/carts/:id/promotions.
|
|
4866
|
+
// Non-idempotent mutation: sent exactly once, never retried.
|
|
4867
|
+
const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
|
|
4868
|
+
method: 'DELETE',
|
|
4869
|
+
headers: {
|
|
4870
|
+
'Content-Type': 'application/json',
|
|
4871
|
+
'x-publishable-api-key': this.publishableKey || '',
|
|
4872
|
+
},
|
|
4873
|
+
body: JSON.stringify({
|
|
4874
|
+
promo_codes: [code],
|
|
4875
|
+
}),
|
|
4876
|
+
});
|
|
3903
4877
|
|
|
3904
|
-
|
|
4878
|
+
if (!response.ok) {
|
|
4879
|
+
const error = await response.json();
|
|
4880
|
+
throw new Error(error.message || 'Failed to remove discount code');
|
|
4881
|
+
}
|
|
3905
4882
|
|
|
3906
|
-
|
|
4883
|
+
this.logger.info('Discount code removed successfully', { cartId, code });
|
|
3907
4884
|
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
return transformCart(updatedCart);
|
|
3911
|
-
},
|
|
3912
|
-
'removeDiscountCode',
|
|
3913
|
-
{ cartId, code },
|
|
3914
|
-
);
|
|
4885
|
+
const updatedCart = await this.retrieveCartWithLineDetails(cartId);
|
|
4886
|
+
return transformCart(updatedCart);
|
|
3915
4887
|
} catch (error: any) {
|
|
3916
4888
|
this.logger.error('Failed to remove discount code', { cartId, code, error: error.message });
|
|
3917
4889
|
throw new ProviderAPIError(
|