@thorprovider/adapters 5.0.10 → 5.0.12

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