@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.
@@ -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 '@thorprovider/types';
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
- type: 'jwt',
243
- // Use localStorage in browser, memory storage on server
244
- jwtTokenStorageMethod: typeof window !== 'undefined' ? 'local' : 'memory',
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
- async getProduct(handle: string, options?: { regionId?: string }): Promise<Product | undefined> {
389
- this.logger.debug('getProduct', { handle, regionId: options?.regionId });
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
- try {
392
- return await this.withRetry(
393
- async () => {
394
- const regionId = options?.regionId ?? this.regionId;
395
- // calculated_price requires region_id in Medusa v2 — only request it when available
396
- const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
397
- const pricingFields = ',+variants.calculated_price,+variants.prices';
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
- const queryParams: any = {
401
- handle,
402
- limit: 1,
403
- fields,
404
- };
405
- // Add region_id for region-based pricing
406
- if (regionId) {
407
- queryParams.region_id = regionId;
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
- // Scope to storefront sales channel when configured.
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
- const { products } = await this.sdk.store.product.list(queryParams);
822
+ if (!products || products.length === 0) {
823
+ this.logger.debug('Product not found', filter);
824
+ return undefined;
825
+ }
417
826
 
418
- if (!products || products.length === 0) {
419
- this.logger.debug('Product not found', { handle });
420
- return undefined;
421
- }
827
+ return transformProduct(products[0], this.currencyCode);
828
+ }
422
829
 
423
- const transformedProduct = transformProduct(products[0], this.currencyCode);
830
+ async getProduct(handle: string, options?: { regionId?: string }): Promise<Product | undefined> {
831
+ this.logger.debug('getProduct', { handle, regionId: options?.regionId });
424
832
 
425
- return transformedProduct;
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 SortOption values to Medusa /store/products `order` field names.
452
- // Native endpoint supports: title, -title, created_at, -created_at
453
- // Price sorting (price-asc, price-desc) is not supported — omitted so
454
- // products-client.tsx handles it client-side as before.
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 ?? 100,
467
- offset: options.after ? parseInt(String(options.after), 10) : undefined,
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 = SORT_KEY_MAP[options.sortKey] ?? null;
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 not available, falling back to getProducts()');
561
- const products = await this.getProducts(options);
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, falling back to getProducts()', {
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
- // Graceful fallback to basic getProducts()
713
- const products = await this.getProducts(options);
714
- return {
715
- products,
716
- meta: {
717
- limit: options?.first ?? 20,
718
- offset: Number(options?.after ?? 0),
719
- hybridSearchUsed: false,
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 (price, stock)
784
- if (options.useAdvancedSearch && this.capabilities.advancedSearch) {
785
- // resolved on all requests. Currently omitted to avoid 400 errors from
786
- // getProducts fallback when regionId is missing.
787
- filters.push({
788
- id: 'price',
789
- label: 'Price',
790
- type: 'range',
791
- min: 0,
792
- max: 10000,
793
- isActive: false,
794
- ranking: 0,
795
- });
796
-
797
- // Stock availability filter
798
- // ID must match the URL param read by page.tsx (?isAvailable=true)
799
- filters.push({
800
- id: 'isAvailable',
801
- label: 'In Stock',
802
- type: 'boolean',
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
- value: this.toSlug(val.value), // URL slug: ?ram=8gb
859
- label: val.value, // Display: "8GB"
860
- id: val.id, // Backend ID: 01KN03N67V13K5S2E6B6695KRN
861
- count: options.includeFacetCounts ? val.count : undefined,
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
- // Add range values for numeric types
866
- if (filterType === 'range' && attr.metadata) {
867
- filter.min = attr.metadata.min || 0;
868
- filter.max = attr.metadata.max || 100;
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
- // Increase limit for client-side filtering to improve search results
1315
- // If query is present, fetch more items (up to 1000) to increase likelihood of finding matches
1316
- const limit = options.query ? 1000 : (options.limit ?? 100);
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
- order: options.sortKey,
1380
- limit: options.first ?? 100,
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.calculated_prices,+variants.inventory_quantity,+options,+images,+tags',
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 ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
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 = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
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.logger.debug('completeCheckout', { cartId });
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
- this.logger.info('Checkout completed successfully', {
2941
- orderId: order.id,
2942
- displayId: order.display_id,
2943
- });
4032
+ if (result.type === 'order') {
4033
+ return result.order;
4034
+ }
2944
4035
 
2945
- return transformOrder(order);
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.info('🚚 [DEBUG] getShippingMethods called', { cartId });
4132
+ this.logger.debug('getShippingMethods', { cartId });
2971
4133
 
2972
4134
  try {
2973
4135
  return await this.withRetry(
2974
4136
  async () => {
2975
- // Get raw cart to access region
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
- // Fetch shipping options for cart
3017
- // Medusa v2 endpoint: GET /store/shipping-options?cart_id=...
3018
- this.logger.info('🚚 [DEBUG] Step 3: Fetching shipping options from Medusa...', {
3019
- cartId,
3020
- regionId: cart.region_id,
3021
- endpoint: `${this.baseUrl}store/shipping-options?cart_id=${cartId}`,
3022
- note: 'About to fetch shipping options (SDK when available, otherwise HTTP)',
3023
- });
3024
-
3025
- let shippingResponse: any;
3026
- let shipping_options: any;
3027
-
3028
- try {
3029
- const storeAny = this.sdk.store as any;
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
- shipping_options = shippingResponse.shipping_options;
3058
- } catch (sdkError: any) {
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
- cartId,
3064
- regionId: cart.region_id,
3065
- errorType: sdkError?.constructor?.name,
3066
- errorMessage: sdkError?.message,
3067
- errorStatus: sdkError?.status,
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
- throw sdkError;
4176
+
4177
+ shippingOptions = ((await response.json()) as any)?.shipping_options ?? [];
3077
4178
  }
3078
4179
 
3079
- // Log raw shipping options response
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
- const methods = shipping_options.map((option: any) => ({
3097
- id: option.id,
3098
- name: option.name,
3099
- price: {
3100
- amount: option.price_incl_tax?.toString() || option.amount?.toString() || '0',
3101
- currencyCode: cart.currency_code?.toUpperCase() || this.currencyCode,
3102
- },
3103
- description: option.data?.description,
3104
- estimatedDays: option.data?.estimated_days || 'N/A',
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
- // Log transformed methods
3108
- this.logger.info('🚚 [DEBUG] Step 5: Transformed methods for Thor types', {
3109
- cartId,
3110
- count: methods.length,
3111
- methods: JSON.stringify(methods, null, 2),
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('🚚 [DEBUG] ERROR: Failed to fetch shipping methods', {
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
- return await this.withRetry(
3575
- async () => {
3576
- // Medusa v2: POST /store/carts/:id/shipping-methods
3577
- const { cart } = await this.sdk.store.cart.addShippingMethod(cartId, {
3578
- option_id: methodId,
3579
- } as any);
3580
-
3581
- this.logger.info('Shipping method added successfully', { cartId, methodId });
3582
-
3583
- void cart;
3584
- const updatedCart = await this.retrieveCartWithLineDetails(cartId);
3585
- return transformCart(updatedCart);
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.info('💳 [DEBUG] initiatePaymentSession called', { cartId, providerId });
4703
+ this.logger.debug('initiatePaymentSession', { cartId, providerId });
3615
4704
 
3616
4705
  try {
3617
- await this.withRetry(
3618
- async () => {
3619
- // Get RAW cart object from Medusa SDK (not transformed)
3620
- // CHANGE 1: Add payment_collection fields to query
3621
- this.logger.info(
3622
- '💳 [DEBUG] Step 1: Retrieving RAW cart from Medusa for payment session...',
3623
- { cartId },
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
- if (existingSessions.length > 0) {
3705
- this.logger.info('💳 [DEBUG] Payment session already exists, skipping creation', {
3706
- cartId: rawCart.id,
3707
- sessionCount: existingSessions.length,
3708
- sessionIds: existingSessions.map((s: any) => s.id),
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
- // Return early - session already exists
3712
- return;
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
- this.logger.info('💳 [DEBUG] Step 2.5: No existing payment sessions, will create new one', {
3716
- cartId: rawCart.id,
3717
- hasPaymentCollection: !!rawCart.payment_collection,
3718
- providerId,
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
- // Medusa v2 SDK: sdk.store.payment.initiatePaymentSession(cart, options)
3722
- this.logger.info('💳 [DEBUG] Step 3: Calling Medusa SDK initiatePaymentSession...', {
3723
- cartId,
3724
- providerId,
3725
- endpoint: `${this.baseUrl}store/payment-collections`,
3726
- note: 'About to call SDK method',
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
- // VALIDATION: Ensure provider_id is a valid backend ID
3730
- // Prevent sending normalized types like 'card', 'wallet', 'cash_on_delivery'
3731
- this.logger.info('💳 [DEBUG] Step 2.5: Validating provider ID...', {
3732
- providerId,
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
- try {
3736
- validatePaymentId(providerId);
3737
- this.logger.info('💳 [DEBUG] Provider ID validation passed', { providerId });
3738
- } catch (validationError: any) {
3739
- this.logger.error('💳 [DEBUG] ERROR: Invalid provider ID detected', {
3740
- providerId,
3741
- error: validationError.message,
3742
- hint: 'Use getBackendProviderId(paymentMethod) to extract correct ID',
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
- let paymentResponse: any;
3752
- try {
3753
- paymentResponse = await this.sdk.store.payment.initiatePaymentSession(rawCart as any, {
3754
- provider_id: providerId,
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
- this.logger.info('💳 [DEBUG] Step 4: Payment session response received', {
3758
- cartId,
3759
- providerId,
3760
- responseKeys: paymentResponse ? Object.keys(paymentResponse) : [],
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
- this.logger.info('💳 [DEBUG] Payment session initiated successfully', {
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('💳 [DEBUG] ERROR: Failed to initiate payment session', {
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
- return await this.withRetry(
3832
- async () => {
3833
- // Medusa v2: POST /store/carts/:id/promotions
3834
- const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
3835
- method: 'POST',
3836
- headers: {
3837
- 'Content-Type': 'application/json',
3838
- 'x-publishable-api-key': this.publishableKey || '',
3839
- },
3840
- body: JSON.stringify({
3841
- promo_codes: [code],
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
- const { cart: medusaCart } = await response.json();
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
- this.logger.info('Discount code applied successfully', { cartId, code });
4837
+ this.logger.info('Discount code applied successfully', { cartId, code });
3853
4838
 
3854
- void medusaCart;
3855
- const updatedCart = await this.retrieveCartWithLineDetails(cartId);
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
- return await this.withRetry(
3886
- async () => {
3887
- // Medusa v2: DELETE /store/carts/:id/promotions
3888
- const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
3889
- method: 'DELETE',
3890
- headers: {
3891
- 'Content-Type': 'application/json',
3892
- 'x-publishable-api-key': this.publishableKey || '',
3893
- },
3894
- body: JSON.stringify({
3895
- promo_codes: [code],
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
- const { cart: medusaCart } = await response.json();
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
- this.logger.info('Discount code removed successfully', { cartId, code });
4883
+ this.logger.info('Discount code removed successfully', { cartId, code });
3907
4884
 
3908
- void medusaCart;
3909
- const updatedCart = await this.retrieveCartWithLineDetails(cartId);
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(