@thorprovider/adapters 3.0.2

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 (95) hide show
  1. package/.turbo/turbo-build.log +18 -0
  2. package/.turbo/turbo-test.log +1 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/CHANGELOG.md +271 -0
  6. package/README.md +634 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/index.d.mts +2524 -0
  9. package/dist/index.d.ts +2524 -0
  10. package/dist/index.js +8136 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/index.mjs +8045 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/docs/RETRY_SOLUTION_SUMMARY.md +55 -0
  15. package/docs/authentication.md +165 -0
  16. package/docs/capabilities-system.md +143 -0
  17. package/docs/category-customization.md +309 -0
  18. package/docs/fetch-reliability-solution.md +399 -0
  19. package/docs/getting-started.md +154 -0
  20. package/docs/multi-tenant-channel-scoping.md +225 -0
  21. package/docs/profile-management.md +147 -0
  22. package/docs/providers-overview.md +85 -0
  23. package/docs/services-overview.md +135 -0
  24. package/docs/update-documentation.md +113 -0
  25. package/package.json +43 -0
  26. package/publish.log +93 -0
  27. package/quick-reference.md +169 -0
  28. package/src/admin/AdminAuthService.ts +88 -0
  29. package/src/admin/AdminSessionService.ts +91 -0
  30. package/src/admin/MetricsService.ts +145 -0
  31. package/src/admin/index.ts +13 -0
  32. package/src/backend/BackendMetadataAdapter.ts +88 -0
  33. package/src/backend/factory.ts +107 -0
  34. package/src/backend/index.ts +12 -0
  35. package/src/backend/medusa/MedusaMetadataAdapter.ts +139 -0
  36. package/src/core/errors.js +161 -0
  37. package/src/core/errors.ts +132 -0
  38. package/src/core/fetch-utils.js +240 -0
  39. package/src/core/fetch-utils.ts +227 -0
  40. package/src/core/index.ts +8 -0
  41. package/src/core/logger.js +63 -0
  42. package/src/core/logger.ts +37 -0
  43. package/src/factory/index.ts +102 -0
  44. package/src/index.ts +205 -0
  45. package/src/lib/money-transform.ts +80 -0
  46. package/src/metadata/channel-scope-validation.js +136 -0
  47. package/src/metadata/channel-scope-validation.ts +151 -0
  48. package/src/metadata/channel-scope.js +174 -0
  49. package/src/metadata/channel-scope.ts +215 -0
  50. package/src/metadata/index.js +31 -0
  51. package/src/metadata/index.ts +37 -0
  52. package/src/providers/bigcommerce/bigcommerce.ts +6 -0
  53. package/src/providers/bigcommerce/index.ts +315 -0
  54. package/src/providers/bigcommerce/transforms.ts +227 -0
  55. package/src/providers/index.ts +22 -0
  56. package/src/providers/magento/index.ts +387 -0
  57. package/src/providers/magento/magento.ts +5 -0
  58. package/src/providers/magento/queries.ts +267 -0
  59. package/src/providers/magento/transforms.ts +249 -0
  60. package/src/providers/medusa/auth.js +601 -0
  61. package/src/providers/medusa/auth.ts +496 -0
  62. package/src/providers/medusa/fulfillment-planner.ts +225 -0
  63. package/src/providers/medusa/index.js +2485 -0
  64. package/src/providers/medusa/index.ts +4239 -0
  65. package/src/providers/medusa/medusa.ts +8 -0
  66. package/src/providers/medusa/payment.js +265 -0
  67. package/src/providers/medusa/payment.ts +269 -0
  68. package/src/providers/medusa/transforms.js +505 -0
  69. package/src/providers/medusa/transforms.ts +625 -0
  70. package/src/providers/shopify/index.ts +444 -0
  71. package/src/providers/shopify/mutations.ts +102 -0
  72. package/src/providers/shopify/queries.ts +324 -0
  73. package/src/providers/shopify/shopify.ts +7 -0
  74. package/src/providers/shopify/transforms.ts +270 -0
  75. package/src/providers/spree/index.ts +344 -0
  76. package/src/providers/spree/spree.ts +5 -0
  77. package/src/providers/spree/transforms.ts +254 -0
  78. package/src/providers/woocommerce/index.ts +310 -0
  79. package/src/providers/woocommerce/transforms.ts +254 -0
  80. package/src/providers/woocommerce/woocommerce.ts +5 -0
  81. package/src/resilience/README.md +299 -0
  82. package/src/resilience/circuit-breaker.ts +222 -0
  83. package/src/resilience/index.ts +10 -0
  84. package/src/types/capabilities.types.js +141 -0
  85. package/src/types/capabilities.types.ts +366 -0
  86. package/src/types/index.ts +24 -0
  87. package/src/types/provider.js +9 -0
  88. package/src/types/provider.ts +9 -0
  89. package/src/types/ui-responses.ts +261 -0
  90. package/src/utils/city-coordinates.ts +40 -0
  91. package/src/utils/geolocation.ts +52 -0
  92. package/src/utils/payment-utils.js +152 -0
  93. package/src/utils/payment-utils.ts +203 -0
  94. package/tsconfig.json +15 -0
  95. package/tsup.config.ts +14 -0
@@ -0,0 +1,4239 @@
1
+ /**
2
+ * @thorprovider/adapters v2.0
3
+ * Medusa Provider - Main implementation
4
+ */
5
+
6
+ import Medusa from '@medusajs/js-sdk';
7
+ import type {
8
+ Product,
9
+ GetProductsOptions,
10
+ AdvancedSearchProductsOptions,
11
+ SearchResultMeta,
12
+ FilterConfig,
13
+ Cart,
14
+ CartLineInput,
15
+ CartLineUpdate,
16
+ Collection,
17
+ CollectionProductsOptions,
18
+ ProductCategory,
19
+ GetCategoriesOptions,
20
+ GetCollectionsOptions,
21
+ StockLocation,
22
+ InventoryLevel,
23
+ StockValidation,
24
+ FulfillmentOption,
25
+ AuthConfig,
26
+ AuthProvider,
27
+ PaymentMethod,
28
+ PaymentMethodsOptions,
29
+ Order,
30
+ StorefrontContext,
31
+ Customer,
32
+ GetCustomersOptions,
33
+ GetOrdersOptions,
34
+ AdminProduct,
35
+ Region,
36
+ Address,
37
+ BackendCapabilities,
38
+ StorefrontConfig,
39
+ DesignerConfig,
40
+ DesignerHistoryEntry,
41
+ } from '@thorprovider/types';
42
+ import { StorefrontConfigError } from '@thorprovider/types';
43
+ import type { CommerceProvider, ProviderConfig } from '../../types/provider';
44
+ import { MEDUSA_CAPABILITIES } from '../../types/capabilities.types';
45
+ import { createLogger } from '../../core/logger';
46
+ import { fetchWithRetry, type RetryConfig } from '../../core/fetch-utils';
47
+ import { ProviderAPIError, ResourceNotFoundError, extractErrorMessage } from '../../core/errors';
48
+ import { validatePaymentId } from '../../utils/payment-utils';
49
+ import { transformProduct, transformCart, transformCollection, transformCategory, transformOrder, transformCustomer, transformStockLocation, transformInventoryLevel, transformAddress, transformStorefrontConfig } from './transforms';
50
+ import { MedusaAuthProvider } from './auth';
51
+ import { getPaymentMethods } from './payment';
52
+ import {
53
+ buildChannelScopeMetadata,
54
+ filterByChannel,
55
+ validateChannelScopeAssignment,
56
+ } from '../../metadata';
57
+
58
+ const MEDUSA_CART_RETRIEVE_FIELDS =
59
+ '+items,+items.variant,+items.variant.product,+items.variant.product.thumbnail,+items.variant.options,+items.variant.options.option';
60
+
61
+ /**
62
+ * Medusa provider configuration
63
+ */
64
+ export interface MedusaProviderConfig extends ProviderConfig {
65
+ /**
66
+ * Medusa backend URL
67
+ */
68
+ baseUrl: string;
69
+
70
+ /**
71
+ * Publishable API key (for storefront access)
72
+ */
73
+ publishableKey: string;
74
+
75
+ /**
76
+ * Default currency code (default: USD)
77
+ */
78
+ /**
79
+ * Default currency code (default: USD)
80
+ */
81
+ currencyCode?: string;
82
+
83
+ /**
84
+ * Region ID (optional, will use default region if not provided)
85
+ */
86
+ regionId?: string;
87
+
88
+ /**
89
+ * Authentication configuration (optional)
90
+ * If not provided, auth methods will not be available
91
+ */
92
+ auth?: AuthConfig;
93
+
94
+ /**
95
+ * Retry configuration for fetch requests (optional)
96
+ * Configure automatic retries, timeouts, and backoff strategy
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * {
101
+ * maxRetries: 3, // Retry up to 3 times
102
+ * timeoutMs: 10000, // 10 second timeout per request
103
+ * initialDelayMs: 300, // Start with 300ms delay
104
+ * }
105
+ * ```
106
+ */
107
+ retry?: RetryConfig;
108
+
109
+ /**
110
+ * Admin API configuration (optional).
111
+ * Required for inventory operations (stock locations, inventory levels).
112
+ * When not provided, admin-only methods will throw with a descriptive error.
113
+ */
114
+ adminConfig?: {
115
+ /**
116
+ * Secret API key created in Medusa Admin → Settings → API Keys → Secret.
117
+ * Sent as `x-medusa-access-token` on every request of the dedicated admin SDK instance.
118
+ * Never use a Publishable key here.
119
+ */
120
+ apiKey: string;
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Medusa commerce provider
126
+ *
127
+ * Implements CommerceProvider interface for Medusa.js v2
128
+ */
129
+ export class MedusaProvider implements CommerceProvider {
130
+ readonly name = 'medusa';
131
+ readonly version = '2.0.0';
132
+
133
+ /**
134
+ * Medusa backend capabilities.
135
+ * Starts with static defaults, replaced by dynamic values from
136
+ * `GET /store/thor/capabilities` once the fetch completes.
137
+ */
138
+ private _dynamicCapabilities: BackendCapabilities | null = null;
139
+
140
+ /** Promise that resolves when capabilities fetch completes (success or failure) */
141
+ private _capabilitiesPromise: Promise<void> | null = null;
142
+
143
+ /**
144
+ * Getter returns dynamic capabilities if available, otherwise static fallback.
145
+ */
146
+ get capabilities(): BackendCapabilities {
147
+ return this._dynamicCapabilities ?? MEDUSA_CAPABILITIES;
148
+ }
149
+
150
+ private sdk: Medusa;
151
+ private logger: ReturnType<typeof createLogger>;
152
+ private currencyCode: string;
153
+ private retryConfig: Required<RetryConfig>;
154
+ private baseUrl: string;
155
+ private publishableKey: string;
156
+
157
+ /** Secret API key for admin operations (if configured). */
158
+ private adminApiKey?: string;
159
+ /**
160
+ * Dedicated SDK instance for admin calls.
161
+ * Separate from `this.sdk` to prevent the admin API key from being overwritten
162
+ * by customer JWT tokens set during storefront authentication.
163
+ * Only created when adminConfig.apiKey is provided.
164
+ */
165
+ private sdkAdmin?: Medusa;
166
+ /** Cached distinct product status values — rarely changes, cached for process lifetime */
167
+ private _cachedProductStatuses: string[] | null = null;
168
+
169
+ /**
170
+ * In-memory cache for region countries
171
+ * Structure: Map<regionId, { countries: Country[], expiresAt: number }>
172
+ */
173
+ private regionCountriesCache = new Map<
174
+ string,
175
+ { countries: import('@thorprovider/types').Country[]; expiresAt: number }
176
+ >();
177
+
178
+ /**
179
+ * Cache TTL for region countries (5 minutes)
180
+ */
181
+ private readonly REGION_COUNTRIES_CACHE_TTL = 5 * 60 * 1000;
182
+
183
+ /**
184
+ * In-memory cache for storefront context (60 second TTL)
185
+ */
186
+ private storefrontContextCache: {
187
+ context: StorefrontContext;
188
+ expiresAt: number;
189
+ } | null = null;
190
+
191
+ /**
192
+ * Cache TTL for storefront context (60 seconds)
193
+ */
194
+ private readonly STOREFRONT_CONTEXT_CACHE_TTL = 60 * 1000;
195
+
196
+ /**
197
+ * Authentication provider (available if auth config is provided)
198
+ */
199
+ public readonly auth?: AuthProvider;
200
+
201
+ private async retrieveCartWithLineDetails(cartId: string): Promise<any> {
202
+ const { cart } = await this.sdk.store.cart.retrieve(cartId, {
203
+ fields: MEDUSA_CART_RETRIEVE_FIELDS,
204
+ } as any);
205
+ return cart;
206
+ }
207
+
208
+ constructor(config: MedusaProviderConfig) {
209
+ this.logger = createLogger('MedusaProvider', config.debug);
210
+ this.currencyCode = config.currencyCode ?? 'USD';
211
+ this.baseUrl = config.baseUrl;
212
+ this.publishableKey = config.publishableKey;
213
+
214
+ // Store retry configuration
215
+ this.retryConfig = {
216
+ maxRetries: config.retry?.maxRetries ?? 3,
217
+ timeoutMs: config.retry?.timeoutMs ?? 10000,
218
+ initialDelayMs: config.retry?.initialDelayMs ?? 300,
219
+ backoffMultiplier: config.retry?.backoffMultiplier ?? 2,
220
+ maxDelayMs: config.retry?.maxDelayMs ?? 5000,
221
+ retryableStatusCodes: config.retry?.retryableStatusCodes ?? [408, 429, 500, 502, 503, 504],
222
+ };
223
+
224
+ this.logger.info('Initializing Medusa Provider', {
225
+ baseUrl: config.baseUrl,
226
+ currencyCode: this.currencyCode,
227
+ hasAuth: !!config.auth,
228
+ hasAdminConfig: !!config.adminConfig,
229
+ retryConfig: this.retryConfig,
230
+ });
231
+
232
+ this.sdk = new Medusa({
233
+ baseUrl: config.baseUrl,
234
+ publishableKey: config.publishableKey,
235
+ debug: config.debug ?? false,
236
+ // Always use JWT mode for auth - SDK needs to send Bearer tokens
237
+ // Configure storage method for JWT tokens (browser only)
238
+ auth: config.auth
239
+ ? {
240
+ type: 'jwt',
241
+ // Use localStorage in browser, memory storage on server
242
+ jwtTokenStorageMethod: typeof window !== 'undefined' ? 'local' : 'memory',
243
+ }
244
+ : undefined,
245
+ });
246
+
247
+ // Create dedicated admin SDK instance with API key (synchronous — no network call)
248
+ if (config.adminConfig?.apiKey) {
249
+ this.adminApiKey = config.adminConfig.apiKey;
250
+ this.sdkAdmin = new Medusa({
251
+ baseUrl: config.baseUrl,
252
+ apiKey: config.adminConfig.apiKey, // → x-medusa-access-token header on all requests
253
+ debug: config.debug ?? false,
254
+ // No publishableKey or auth config: this instance is admin-only, no JWT needed
255
+ });
256
+ this.logger.info('[Admin Auth] Dedicated admin SDK instance created with API key');
257
+ }
258
+
259
+ // Initialize auth provider if config is provided
260
+ if (config.auth) {
261
+ this.auth = new MedusaAuthProvider(this.sdk, config.auth, config.debug);
262
+ this.logger.info('Auth provider initialized');
263
+ }
264
+
265
+ // Fetch dynamic capabilities from Thor Commerce plugin (non-blocking).
266
+ // Falls back to MEDUSA_CAPABILITIES if plugin is not installed.
267
+ this._capabilitiesPromise = this._fetchCapabilities();
268
+ }
269
+
270
+ // ============================================
271
+ // Thor Plugin — Dynamic Capabilities
272
+ // ============================================
273
+
274
+ /**
275
+ * Fetch capabilities dynamically from `GET /store/thor/capabilities`.
276
+ * Non-blocking — called in constructor. On failure, static fallback is used.
277
+ * Cache: process-lifetime (per provider instance).
278
+ */
279
+ private async _fetchCapabilities(): Promise<void> {
280
+ try {
281
+ const url = `${this.baseUrl}/store/thor/capabilities`;
282
+ const response = await fetchWithRetry(
283
+ url,
284
+ {
285
+ method: 'GET',
286
+ headers: {
287
+ 'x-publishable-api-key': this.publishableKey,
288
+ 'Content-Type': 'application/json',
289
+ },
290
+ },
291
+ this.retryConfig,
292
+ );
293
+
294
+ if (!response.ok) {
295
+ this.logger.warn('[Capabilities] Thor plugin endpoint returned non-OK status', {
296
+ status: response.status,
297
+ });
298
+ return;
299
+ }
300
+
301
+ const data = await response.json();
302
+ this._dynamicCapabilities = data as BackendCapabilities;
303
+ this.logger.info('[Capabilities] Dynamic capabilities loaded from backend');
304
+ } catch (error) {
305
+ // Non-fatal — static MEDUSA_CAPABILITIES remains as fallback
306
+ this.logger.warn('[Capabilities] Failed to fetch dynamic capabilities, using static defaults', {
307
+ error: error instanceof Error ? error.message : String(error),
308
+ });
309
+ }
310
+ }
311
+
312
+ // ============================================
313
+ // Product Operations
314
+ // ============================================
315
+
316
+ /**
317
+ * Wrapper for SDK calls with retry logic
318
+ * Handles network failures and temporary backend issues
319
+ */
320
+ private async withRetry<T>(
321
+ operation: () => Promise<T>,
322
+ operationName: string,
323
+ context?: Record<string, any>,
324
+ ): Promise<T> {
325
+ let lastError: any;
326
+
327
+ for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
328
+ try {
329
+ // Create timeout promise
330
+ const timeoutPromise = new Promise<never>((_, reject) => {
331
+ setTimeout(() => {
332
+ reject(new Error(`Request timeout after ${this.retryConfig.timeoutMs}ms`));
333
+ }, this.retryConfig.timeoutMs);
334
+ });
335
+
336
+ // Race between operation and timeout
337
+ const result = await Promise.race([operation(), timeoutPromise]);
338
+
339
+ if (attempt > 0) {
340
+ this.logger.info(`${operationName} succeeded after ${attempt} retries`, context);
341
+ }
342
+
343
+ return result;
344
+ } catch (error: any) {
345
+ lastError = error;
346
+
347
+ // Check if we should retry
348
+ const isRetryable =
349
+ error.message?.includes('fetch failed') ||
350
+ error.message?.includes('timeout') ||
351
+ error.message?.includes('ECONNREFUSED') ||
352
+ error.message?.includes('ETIMEDOUT') ||
353
+ (error.status && this.retryConfig.retryableStatusCodes.includes(error.status));
354
+
355
+ const shouldRetry = attempt < this.retryConfig.maxRetries && isRetryable;
356
+
357
+ if (shouldRetry) {
358
+ const delay =
359
+ this.retryConfig.initialDelayMs * Math.pow(this.retryConfig.backoffMultiplier, attempt);
360
+ const cappedDelay = Math.min(delay, this.retryConfig.maxDelayMs);
361
+
362
+ this.logger.warn(`${operationName} failed, retrying...`, {
363
+ ...context,
364
+ attempt: attempt + 1,
365
+ maxAttempts: this.retryConfig.maxRetries + 1,
366
+ nextRetryIn: `${cappedDelay}ms`,
367
+ error: error.message,
368
+ });
369
+
370
+ await new Promise((resolve) => setTimeout(resolve, cappedDelay));
371
+ } else {
372
+ this.logger.error(`${operationName} failed after all retries`, {
373
+ ...context,
374
+ totalAttempts: attempt + 1,
375
+ error: error.message,
376
+ });
377
+ break;
378
+ }
379
+ }
380
+ }
381
+
382
+ // All retries exhausted
383
+ throw lastError;
384
+ }
385
+
386
+ async getProduct(handle: string, options?: { regionId?: string }): Promise<Product | undefined> {
387
+ this.logger.debug('getProduct', { handle, regionId: options?.regionId });
388
+
389
+ try {
390
+ return await this.withRetry(
391
+ async () => {
392
+ // calculated_price requires region_id in Medusa v2 — only request it when available
393
+ const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
394
+ const pricingFields = ',+variants.calculated_price,+variants.prices';
395
+ const fields = options?.regionId ? baseFields + pricingFields : baseFields;
396
+
397
+ const queryParams: any = {
398
+ handle,
399
+ limit: 1,
400
+ fields,
401
+ };
402
+ // Add region_id for region-based pricing
403
+ if (options?.regionId) {
404
+ queryParams.region_id = options.regionId;
405
+ }
406
+
407
+ // Scope to storefront sales channel when configured.
408
+ const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
409
+ if (salesChannelId) {
410
+ queryParams.sales_channel_id = [salesChannelId];
411
+ }
412
+
413
+ const { products } = await this.sdk.store.product.list(queryParams);
414
+
415
+ if (!products || products.length === 0) {
416
+ this.logger.debug('Product not found', { handle });
417
+ return undefined;
418
+ }
419
+
420
+ const transformedProduct = transformProduct(products[0], this.currencyCode);
421
+
422
+ return transformedProduct;
423
+ },
424
+ 'getProduct',
425
+ { handle, regionId: options?.regionId },
426
+ );
427
+ } catch (error: any) {
428
+ throw new ProviderAPIError(
429
+ `Failed to fetch product: ${error.message}`,
430
+ 'medusa',
431
+ error.status,
432
+ );
433
+ }
434
+ }
435
+
436
+ async getProducts(options: GetProductsOptions & { regionId?: string } = {}): Promise<Product[]> {
437
+ this.logger.debug('getProducts', options);
438
+
439
+ try {
440
+ return await this.withRetry(
441
+ async () => {
442
+ // calculated_price requires region_id in Medusa v2 — only request it when available
443
+ const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
444
+ const pricingFields = ',+variants.calculated_price,+variants.prices';
445
+ const fields = options.regionId ? baseFields + pricingFields : baseFields;
446
+
447
+ // Map Thor SortOption values to Medusa /store/products `order` field names.
448
+ // Native endpoint supports: title, -title, created_at, -created_at
449
+ // Price sorting (price-asc, price-desc) is not supported — omitted so
450
+ // products-client.tsx handles it client-side as before.
451
+ const SORT_KEY_MAP: Record<string, string | null> = {
452
+ featured: null, // server default order
453
+ name: 'title', // A→Z by product title
454
+ newest: '-created_at', // newest products first
455
+ 'price-asc': null, // not supported by native endpoint → client-side
456
+ 'price-desc': null, // not supported by native endpoint → client-side
457
+ rating: null, // not supported → server default
458
+ };
459
+
460
+ const queryParams: any = {
461
+ q: options.query,
462
+ limit: options.first ?? 100,
463
+ offset: options.after ? parseInt(String(options.after), 10) : undefined,
464
+ fields,
465
+ };
466
+
467
+ if (options.sortKey) {
468
+ const mappedOrder = SORT_KEY_MAP[options.sortKey] ?? null;
469
+ if (mappedOrder !== null) {
470
+ queryParams.order = mappedOrder;
471
+ }
472
+ // If null → omit order param entirely (server returns default order)
473
+ }
474
+
475
+ // Add region_id for region-based pricing
476
+ if (options.regionId) {
477
+ queryParams.region_id = options.regionId;
478
+ }
479
+
480
+ // Basic category/collection filtering
481
+ // Medusa v2 /store/products supports category_id[] and collection_id[]
482
+ if (options.categoryIds && options.categoryIds.length > 0) {
483
+ queryParams.category_id = options.categoryIds;
484
+ }
485
+ if (options.collectionIds && options.collectionIds.length > 0) {
486
+ queryParams.collection_id = options.collectionIds;
487
+ }
488
+
489
+ // Scope to storefront sales channel when configured.
490
+ // The publishable API key already scopes most requests, but
491
+ // explicit filtering ensures multi-tenant isolation.
492
+ const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
493
+ if (salesChannelId) {
494
+ queryParams.sales_channel_id = [salesChannelId];
495
+ }
496
+
497
+ const { products } = await this.sdk.store.product.list(queryParams);
498
+
499
+ return products.map((p) => transformProduct(p, this.currencyCode));
500
+ },
501
+ 'getProducts',
502
+ options,
503
+ );
504
+ } catch (error: any) {
505
+ // Enhanced error logging
506
+ this.logger.error('getProducts error details', {
507
+ message: error.message,
508
+ status: error.status,
509
+ type: error.type,
510
+ code: error.code,
511
+ stack: error.stack,
512
+ fullError: JSON.stringify(error, null, 2),
513
+ });
514
+
515
+ throw new ProviderAPIError(
516
+ `Failed to fetch products: ${error.message || 'Unknown error'}`,
517
+ 'medusa',
518
+ error.status,
519
+ );
520
+ }
521
+ }
522
+
523
+ async getProductRecommendations(productId: string): Promise<Product[]> {
524
+ this.logger.debug('getProductRecommendations', { productId });
525
+
526
+ // Medusa doesn't have built-in recommendations yet
527
+ // Return empty array for now - can be extended with custom logic
528
+ this.logger.warn('Product recommendations not yet implemented for Medusa');
529
+ return [];
530
+ }
531
+
532
+ // ============================================
533
+ // Advanced Search Operations
534
+ // ============================================
535
+
536
+ /**
537
+ * Advanced product search using /store/search endpoint
538
+ *
539
+ * Uses hybrid MeiliSearch + Qdrant search with advanced filtering:
540
+ * - Price range filtering
541
+ * - Stock availability
542
+ * - Custom product attributes
543
+ *
544
+ * Falls back to getProducts() if advanced search fails
545
+ */
546
+ async searchProductsAdvanced(
547
+ options?: AdvancedSearchProductsOptions
548
+ ): Promise<{
549
+ products: Product[];
550
+ meta?: SearchResultMeta;
551
+ }> {
552
+ this.logger.debug('searchProductsAdvanced', options);
553
+
554
+ // Check if advanced search is available
555
+ if (!this.capabilities.advancedSearch) {
556
+ this.logger.warn('Advanced search not available, falling back to getProducts()');
557
+ const products = await this.getProducts(options);
558
+ return {
559
+ products,
560
+ meta: {
561
+ limit: options?.first ?? 20,
562
+ offset: Number(options?.after ?? 0),
563
+ hybridSearchUsed: false,
564
+ },
565
+ };
566
+ }
567
+
568
+ try {
569
+ // Build query parameters for /store/search
570
+ const queryParams: Record<string, any> = {};
571
+
572
+ // Text search
573
+ if (options?.query) {
574
+ queryParams.q = options.query;
575
+ }
576
+
577
+ // Pagination
578
+ queryParams.limit = options?.first ?? 20;
579
+ queryParams.offset = options?.after ? parseInt(String(options.after), 10) : 0;
580
+
581
+ // Sort — map SortOption values to Medusa's sortable field names.
582
+ // Price sorting is NOT included: backend does not support it yet.
583
+ const SORT_OPTION_TO_ORDER: Record<string, string | null> = {
584
+ featured: null, // no order → server default relevance
585
+ 'price-asc': null, // ❌ not supported by backend; client handles it
586
+ 'price-desc': null, // ❌ not supported by backend; client handles it
587
+ name: 'title', // ✅ THE FIX — was sending 'name', must be 'title'
588
+ newest: '-created_at', // standard Medusa field
589
+ rating: null, // not supported; use server default
590
+ };
591
+
592
+ if (options?.sortKey) {
593
+ const mapped = SORT_OPTION_TO_ORDER[options.sortKey];
594
+ if (mapped !== undefined && mapped !== null) {
595
+ // If the mapped value already contains '-', respect it; otherwise apply reverse flag
596
+ queryParams.order = options.reverse && !mapped.startsWith('-') ? `-${mapped}` : mapped;
597
+ }
598
+ // If mapped === null → omit order param (server default)
599
+ }
600
+
601
+ // Native MeiliSearch filters (fast)
602
+ if (options?.categoryIds && options.categoryIds.length > 0) {
603
+ queryParams.categories = options.categoryIds.join(',');
604
+ }
605
+
606
+ if (options?.collectionIds && options.collectionIds.length > 0) {
607
+ queryParams.collections = options.collectionIds.join(',');
608
+ }
609
+
610
+ if (options?.tagIds && options.tagIds.length > 0) {
611
+ queryParams.tags = options.tagIds.join(',');
612
+ }
613
+
614
+ // Hybrid filters (MeiliSearch + Database)
615
+ if (options?.priceMin !== undefined) {
616
+ queryParams.price_min = options.priceMin;
617
+ }
618
+
619
+ if (options?.priceMax !== undefined) {
620
+ queryParams.price_max = options.priceMax;
621
+ }
622
+
623
+ // Only send isAvailable=true. Sending false causes the backend to return
624
+ // inventory_quantity=0 for all variants, incorrectly marking them out of stock.
625
+ if (options?.inStockOnly === true) {
626
+ queryParams.isAvailable = true;
627
+ }
628
+
629
+ // Custom attributes filtering
630
+ if (options?.attributes && options.attributes.length > 0) {
631
+ queryParams.attributes = JSON.stringify(options.attributes);
632
+ }
633
+
634
+ // Price context
635
+ if (options?.currencyCode) {
636
+ queryParams.currency_code = options.currencyCode;
637
+ }
638
+
639
+ if (options?.regionId) {
640
+ queryParams.region_id = options.regionId;
641
+ }
642
+
643
+ // Note: /store/search always returns variants with calculated prices and stock.
644
+ // includeVariants and includeFacets are NOT supported by this endpoint.
645
+
646
+ // Make request to /store/search
647
+ const url = new URL(`${this.baseUrl}/store/search`);
648
+ Object.entries(queryParams).forEach(([key, value]) => {
649
+ if (value !== undefined && value !== null) {
650
+ url.searchParams.append(key, String(value));
651
+ }
652
+ });
653
+
654
+ this.logger.debug('Calling /store/search', { url: url.toString() });
655
+
656
+ const response = await fetchWithRetry(
657
+ url.toString(),
658
+ {
659
+ method: 'GET',
660
+ headers: {
661
+ 'x-publishable-api-key': this.publishableKey,
662
+ 'Content-Type': 'application/json',
663
+ },
664
+ },
665
+ this.retryConfig
666
+ );
667
+
668
+ if (!response.ok) {
669
+ throw new Error(`Search API returned ${response.status}: ${response.statusText}`);
670
+ }
671
+
672
+ const data = await response.json();
673
+
674
+ // Transform products
675
+ const products = (data.hits || []).map((hit: any) =>
676
+ transformProduct(hit, options?.currencyCode || this.currencyCode)
677
+ );
678
+
679
+ // Build metadata
680
+ const meta: SearchResultMeta = {
681
+ total: data.estimatedTotalHits || data.count,
682
+ limit: data.limit || queryParams.limit,
683
+ offset: data.offset || queryParams.offset,
684
+ processingTimeMs: data.processingTimeMs,
685
+ hybridSearchUsed: data.hybrid_query_applied || false,
686
+ appliedFilters: data.applied_filters,
687
+ priceContext: data.price_context
688
+ ? {
689
+ currencyCode: data.price_context.currency_code,
690
+ regionId: data.price_context.region_id,
691
+ }
692
+ : undefined,
693
+ };
694
+
695
+ this.logger.debug('Search completed', {
696
+ productsFound: products.length,
697
+ totalResults: meta.total,
698
+ processingTime: meta.processingTimeMs,
699
+ });
700
+
701
+ return { products, meta };
702
+ } catch (error: any) {
703
+ this.logger.error('Advanced search failed, falling back to getProducts()', {
704
+ error: error.message,
705
+ stack: error.stack,
706
+ });
707
+
708
+ // Graceful fallback to basic getProducts()
709
+ const products = await this.getProducts(options);
710
+ return {
711
+ products,
712
+ meta: {
713
+ limit: options?.first ?? 20,
714
+ offset: Number(options?.after ?? 0),
715
+ hybridSearchUsed: false,
716
+ },
717
+ };
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Get available filter options for product filtering
723
+ *
724
+ * Aggregates filters from:
725
+ * - Basic filters: categories, collections (always available)
726
+ * - Advanced filters: price range, stock (if useAdvancedSearch=true)
727
+ * - Custom attributes: category-specific attributes from backend
728
+ *
729
+ * Returns empty array if backend doesn't support dynamic filters
730
+ */
731
+ async getAvailableFilters(options: {
732
+ categoryId?: string;
733
+ useAdvancedSearch?: boolean;
734
+ includeBasicFilters?: boolean;
735
+ includeFacetCounts?: boolean;
736
+ }): Promise<FilterConfig[]> {
737
+ this.logger.debug('getAvailableFilters', options);
738
+
739
+ const filters: FilterConfig[] = [];
740
+
741
+ try {
742
+ // 1. Basic filters (categories, collections)
743
+ if (options.includeBasicFilters !== false) {
744
+ // Categories filter
745
+ const categories = await this.getCategories({ includeDescendants: false });
746
+ if (categories.length > 0) {
747
+ filters.push({
748
+ id: 'categories',
749
+ label: 'Categories',
750
+ type: 'multi',
751
+ options: categories.map(cat => ({
752
+ value: cat.handle ?? cat.id, // URL slug: ?categories=electronics
753
+ label: cat.name, // Display: "Electronics"
754
+ id: cat.id, // Backend ID: pcat_01KMND...
755
+ })),
756
+ isActive: false,
757
+ ranking: 10, // Lower = higher priority
758
+ });
759
+ }
760
+
761
+ // Collections filter
762
+ const collections = await this.getCollections({ limit: 100 });
763
+ if (collections.length > 0) {
764
+ filters.push({
765
+ id: 'collections',
766
+ label: 'Collections',
767
+ type: 'multi',
768
+ options: collections.map(col => ({
769
+ value: col.handle ?? col.id, // URL slug: ?collections=summer-2024
770
+ label: col.title, // Display: "Summer 2024"
771
+ id: col.id, // Backend ID: pcol_01KMND...
772
+ })),
773
+ isActive: false,
774
+ ranking: 20,
775
+ });
776
+ }
777
+ }
778
+
779
+ // 2. Advanced filters (price, stock)
780
+ if (options.useAdvancedSearch && this.capabilities.advancedSearch) {
781
+ // resolved on all requests. Currently omitted to avoid 400 errors from
782
+ // getProducts fallback when regionId is missing.
783
+ filters.push({
784
+ id: 'price',
785
+ label: 'Price',
786
+ type: 'range',
787
+ min: 0,
788
+ max: 10000,
789
+ isActive: false,
790
+ ranking: 0,
791
+ });
792
+
793
+ // Stock availability filter
794
+ // ID must match the URL param read by page.tsx (?isAvailable=true)
795
+ filters.push({
796
+ id: 'isAvailable',
797
+ label: 'In Stock',
798
+ type: 'boolean',
799
+ isActive: false,
800
+ ranking: 5,
801
+ });
802
+ }
803
+
804
+ // 3. Custom attributes (category-specific)
805
+ // Guard: skip API call entirely when the customAttributes capability is disabled.
806
+ // This prevents a network request to /store/custom-attributes/category/:id and
807
+ // avoids rendering attribute filters that the backend hasn't enabled as a capability.
808
+ if (options.categoryId && this.capabilities.customAttributes) {
809
+ try {
810
+ const url = `${this.baseUrl}/store/custom-attributes/category/${options.categoryId}`;
811
+ this.logger.debug('Fetching custom attributes', { url });
812
+
813
+ const response = await fetchWithRetry(
814
+ url,
815
+ {
816
+ method: 'GET',
817
+ headers: {
818
+ 'x-publishable-api-key': this.publishableKey,
819
+ 'Content-Type': 'application/json',
820
+ },
821
+ },
822
+ this.retryConfig
823
+ );
824
+
825
+ if (response.ok) {
826
+ const data = await response.json();
827
+ const rawAttributes = data.attributes || [];
828
+
829
+ // Transform backend attributes to FilterConfig
830
+ // Backend response shape: { attributes: [{ attribute_id, attribute: { name, handle, ... } }] }
831
+ // The actual attribute data is nested inside the "attribute" property.
832
+ rawAttributes.forEach((item: any) => {
833
+ // Unwrap nested structure: item may be { attribute_id, attribute: {...} }
834
+ // or a flat object with properties directly on it
835
+ const attr = item.attribute ?? item;
836
+
837
+ if (!attr.is_filterable || !attr.is_active) {
838
+ return; // Skip non-filterable or inactive attributes
839
+ }
840
+
841
+ const filterType = this.mapAttributeTypeToFilterType(attr.type);
842
+
843
+ const filter: FilterConfig = {
844
+ id: attr.handle,
845
+ label: attr.name,
846
+ type: filterType,
847
+ isActive: false,
848
+ ranking: attr.ranking || 50, // Custom attributes lower priority
849
+ };
850
+
851
+ // Add options for single/multi types
852
+ if ((filterType === 'single' || filterType === 'multi') && attr.values) {
853
+ filter.options = attr.values.map((val: any) => ({
854
+ value: this.toSlug(val.value), // URL slug: ?ram=8gb
855
+ label: val.value, // Display: "8GB"
856
+ id: val.id, // Backend ID: 01KN03N67V13K5S2E6B6695KRN
857
+ count: options.includeFacetCounts ? val.count : undefined,
858
+ }));
859
+ }
860
+
861
+ // Add range values for numeric types
862
+ if (filterType === 'range' && attr.metadata) {
863
+ filter.min = attr.metadata.min || 0;
864
+ filter.max = attr.metadata.max || 100;
865
+ }
866
+
867
+ filters.push(filter);
868
+ });
869
+
870
+ this.logger.debug('Custom attributes loaded', {
871
+ categoryId: options.categoryId,
872
+ attributesCount: rawAttributes.length,
873
+ filterableCount: filters.length,
874
+ });
875
+ } else {
876
+ this.logger.warn('Custom attributes endpoint not available', {
877
+ status: response.status,
878
+ categoryId: options.categoryId,
879
+ });
880
+ }
881
+ } catch (attrError: any) {
882
+ // Don't fail if custom attributes unavailable
883
+ this.logger.warn('Failed to load custom attributes, continuing without them', {
884
+ error: attrError.message,
885
+ categoryId: options.categoryId,
886
+ });
887
+ }
888
+ }
889
+
890
+ // Sort by ranking (lower = higher priority)
891
+ filters.sort((a, b) => (a.ranking ?? 0) - (b.ranking ?? 0));
892
+
893
+ this.logger.debug('Filters aggregated', {
894
+ totalFilters: filters.length,
895
+ filterIds: filters.map(f => f.id),
896
+ });
897
+
898
+ return filters;
899
+ } catch (error: any) {
900
+ this.logger.error('Failed to get available filters', {
901
+ error: error.message,
902
+ stack: error.stack,
903
+ });
904
+
905
+ // Return empty array on error (don't block UI)
906
+ return [];
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Convert a human-readable string to a URL-safe slug.
912
+ * Used to produce SEO-friendly filter values (option.value) that go into the URL.
913
+ *
914
+ * Examples:
915
+ * "8GB RAM" → "8gb-ram"
916
+ * "Red Color" → "red-color"
917
+ * "16 GB" → "16-gb"
918
+ */
919
+ private toSlug(str: string): string {
920
+ return str
921
+ .toLowerCase()
922
+ .replace(/\s+/g, '-')
923
+ .replace(/[^a-z0-9-]/g, '');
924
+ }
925
+
926
+ /**
927
+ * Map backend attribute type to filter type
928
+ */
929
+ private mapAttributeTypeToFilterType(
930
+ backendType: string
931
+ ): 'single' | 'multi' | 'boolean' | 'range' {
932
+ switch (backendType) {
933
+ case 'single-select':
934
+ return 'single';
935
+ case 'multi-select':
936
+ return 'multi';
937
+ case 'boolean':
938
+ return 'boolean';
939
+ case 'number':
940
+ case 'range':
941
+ return 'range';
942
+ default:
943
+ return 'multi'; // Default to multi-select
944
+ }
945
+ }
946
+
947
+ // ============================================
948
+ // Collection Operations
949
+ // ============================================
950
+
951
+ async getCollection(handle: string): Promise<Collection | undefined> {
952
+ this.logger.debug('getCollection', { handle });
953
+
954
+ try {
955
+ const { collections } = (await this.sdk.store.collection.list({
956
+ handle: [handle],
957
+ limit: 1,
958
+ })) as any;
959
+
960
+ if (!collections || collections.length === 0) {
961
+ this.logger.debug('Collection not found', { handle });
962
+ return undefined;
963
+ }
964
+
965
+ return transformCollection(collections[0]);
966
+ } catch (error: any) {
967
+ this.logger.error('Failed to fetch collection', { handle, error: error.message });
968
+ throw new ProviderAPIError(
969
+ `Failed to fetch collection: ${error.message}`,
970
+ 'medusa',
971
+ error.status,
972
+ );
973
+ }
974
+ }
975
+
976
+ async getCollections(options?: GetCollectionsOptions): Promise<Collection[]> {
977
+ this.logger.debug('getCollections', { options });
978
+
979
+ try {
980
+ // Increase limit for client-side filtering to improve search results
981
+ // If query is present, fetch more items (up to 1000) to increase likelihood of finding matches
982
+ const limit = options?.query ? 1000 : (options?.limit || 100);
983
+
984
+ const { collections } = (await this.sdk.store.collection.list({
985
+ limit,
986
+ })) as any;
987
+
988
+ let result = collections.map(transformCollection);
989
+
990
+ // Client-side search filtering (Medusa Store API lacks query param for collections)
991
+ if (options?.query) {
992
+ const q = options.query.toLowerCase();
993
+ result = result.filter((c: Collection) =>
994
+ c.title.toLowerCase().includes(q) ||
995
+ c.handle.toLowerCase().includes(q)
996
+ );
997
+ }
998
+
999
+ return result;
1000
+ } catch (error: any) {
1001
+ this.logger.error('Failed to fetch collections', { error: error.message });
1002
+ throw new ProviderAPIError(
1003
+ `Failed to fetch collections: ${error.message}`,
1004
+ 'medusa',
1005
+ error.status,
1006
+ );
1007
+ }
1008
+ }
1009
+
1010
+ async getCollectionProducts(
1011
+ handle: string,
1012
+ options: CollectionProductsOptions = {},
1013
+ ): Promise<Product[]> {
1014
+ this.logger.debug('getCollectionProducts', { handle, options });
1015
+
1016
+ try {
1017
+ // First get the collection to get its ID
1018
+ const collection = await this.getCollection(handle);
1019
+ if (!collection) {
1020
+ throw new ResourceNotFoundError('Collection', handle);
1021
+ }
1022
+
1023
+ // Then get products for this collection
1024
+ const collectionQueryParams: any = {
1025
+ collection_id: [collection.handle], // Medusa uses handle in queries
1026
+ order: options.sortKey,
1027
+ limit: options.first ?? 100,
1028
+ offset: options.after ? parseInt(options.after, 10) : undefined,
1029
+ fields:
1030
+ '+variants,+variants.prices,+variants.calculated_prices,+variants.inventory_quantity,+options,+images,+tags',
1031
+ };
1032
+
1033
+ // Scope to storefront sales channel when configured.
1034
+ const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1035
+ if (salesChannelId) {
1036
+ collectionQueryParams.sales_channel_id = [salesChannelId];
1037
+ }
1038
+
1039
+ const { products } = await this.sdk.store.product.list(collectionQueryParams);
1040
+
1041
+ return products.map((p) => transformProduct(p, this.currencyCode));
1042
+ } catch (error: any) {
1043
+ if (error instanceof ResourceNotFoundError) throw error;
1044
+
1045
+ this.logger.error('Failed to fetch collection products', { handle, error: error.message });
1046
+ throw new ProviderAPIError(
1047
+ `Failed to fetch collection products: ${error.message}`,
1048
+ 'medusa',
1049
+ error.status,
1050
+ );
1051
+ }
1052
+ }
1053
+
1054
+ // ============================================
1055
+ // Category Operations
1056
+ // ============================================
1057
+
1058
+ async getCategory(idOrHandle: string): Promise<ProductCategory | undefined> {
1059
+ this.logger.debug('getCategory', { idOrHandle });
1060
+
1061
+ try {
1062
+ const { product_categories } = (await this.sdk.store.category.list({
1063
+ handle: [idOrHandle],
1064
+ limit: 1,
1065
+ })) as any;
1066
+
1067
+ if (!product_categories || product_categories.length === 0) {
1068
+ this.logger.debug('Category not found', { idOrHandle });
1069
+ return undefined;
1070
+ }
1071
+
1072
+ return transformCategory(product_categories[0]);
1073
+ } catch (error: any) {
1074
+ this.logger.error('Failed to fetch category', { idOrHandle, error: error.message });
1075
+ throw new ProviderAPIError(
1076
+ `Failed to fetch category: ${error.message}`,
1077
+ 'medusa',
1078
+ error.status,
1079
+ );
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * Returns the set of product-category IDs that have at least one product
1085
+ * available in the given sales channel.
1086
+ *
1087
+ * Because Medusa's store/product-categories endpoint does not accept a
1088
+ * `sales_channel_id` filter, we derive the active category IDs by querying
1089
+ * store/products with the sales-channel scope and collecting the category
1090
+ * relations from the response. Pagination is handled internally so the
1091
+ * result is always complete regardless of catalog size.
1092
+ *
1093
+ * @deprecated Use `_getChannelCategories()` instead (backed by Thor Commerce plugin).
1094
+ * Kept as fallback for backends without the plugin installed.
1095
+ */
1096
+ private async _getActiveCategoryIds(salesChannelId: string): Promise<Set<string>> {
1097
+ this.logger.debug('_getActiveCategoryIds', { salesChannelId });
1098
+ const categoryIds = new Set<string>();
1099
+ const limit = 500;
1100
+ let offset = 0;
1101
+
1102
+ // eslint-disable-next-line no-constant-condition
1103
+ while (true) {
1104
+ const { products } = (await this.sdk.store.product.list({
1105
+ sales_channel_id: [salesChannelId],
1106
+ // Request only the category id relation to minimise payload size
1107
+ fields: '+categories.id',
1108
+ limit,
1109
+ offset,
1110
+ } as any)) as any;
1111
+
1112
+ for (const product of products ?? []) {
1113
+ for (const cat of product.categories ?? []) {
1114
+ if (cat?.id) categoryIds.add(cat.id);
1115
+ }
1116
+ }
1117
+
1118
+ if (!products || products.length < limit) break;
1119
+ offset += limit;
1120
+ }
1121
+
1122
+ this.logger.debug('_getActiveCategoryIds result', { count: categoryIds.size });
1123
+ return categoryIds;
1124
+ }
1125
+
1126
+ /**
1127
+ * Fetch channel-scoped categories from Thor Commerce plugin.
1128
+ * Uses `GET /store/thor/categories` — a single indexed query.
1129
+ * Falls back to `_getActiveCategoryIds()` if the plugin is not available.
1130
+ */
1131
+ private async _getChannelCategories(salesChannelId: string): Promise<Set<string>> {
1132
+ try {
1133
+ const url = `${this.baseUrl}/store/thor/categories`;
1134
+ const response = await fetchWithRetry(
1135
+ url,
1136
+ {
1137
+ method: 'GET',
1138
+ headers: {
1139
+ 'x-publishable-api-key': this.publishableKey,
1140
+ 'Content-Type': 'application/json',
1141
+ },
1142
+ },
1143
+ this.retryConfig,
1144
+ );
1145
+
1146
+ if (response.status === 404) {
1147
+ // Plugin not installed — fall back to legacy N+1 method
1148
+ this.logger.debug('Thor plugin categories endpoint not found, falling back to legacy method');
1149
+ return this._getActiveCategoryIds(salesChannelId);
1150
+ }
1151
+
1152
+ if (!response.ok) {
1153
+ this.logger.warn('Thor plugin categories endpoint returned non-OK status', {
1154
+ status: response.status,
1155
+ });
1156
+ return this._getActiveCategoryIds(salesChannelId);
1157
+ }
1158
+
1159
+ const data = await response.json();
1160
+ const categoryIds = new Set<string>();
1161
+ for (const cat of data.categories ?? []) {
1162
+ if (cat?.id) categoryIds.add(cat.id);
1163
+ }
1164
+
1165
+ this.logger.debug('_getChannelCategories result (Thor plugin)', { count: categoryIds.size });
1166
+ return categoryIds;
1167
+ } catch (error) {
1168
+ this.logger.warn('Failed to fetch channel categories from Thor plugin, falling back to legacy method', {
1169
+ error: error instanceof Error ? error.message : String(error),
1170
+ });
1171
+ return this._getActiveCategoryIds(salesChannelId);
1172
+ }
1173
+ }
1174
+
1175
+ async getCategories(options: GetCategoriesOptions = {}): Promise<ProductCategory[]> {
1176
+ this.logger.debug('getCategories', { options });
1177
+
1178
+ const { customFetcher } = options;
1179
+
1180
+ // If custom fetcher provided, use it instead of default SDK logic
1181
+ if (customFetcher) {
1182
+ this.logger.debug('Using custom fetcher for categories');
1183
+ return customFetcher(options);
1184
+ }
1185
+
1186
+ try {
1187
+ // Increase limit for client-side filtering to improve search results
1188
+ // If query is present, fetch more items (up to 1000) to increase likelihood of finding matches
1189
+ const limit = options.query ? 1000 : (options.limit ?? 100);
1190
+
1191
+ const queryParams: any = {
1192
+ limit,
1193
+ include_descendants_tree: options.includeDescendants ?? false,
1194
+ };
1195
+
1196
+ if (options.parentCategoryId) {
1197
+ queryParams.parent_category_id = options.parentCategoryId;
1198
+ }
1199
+
1200
+ const { product_categories } = (await this.sdk.store.category.list(queryParams)) as any;
1201
+
1202
+ let result = product_categories.map(transformCategory);
1203
+
1204
+ // --- Sales channel scoping ---
1205
+ // Prefer Thor Commerce plugin endpoint (single indexed query).
1206
+ // Falls back to legacy N+1 method if plugin is not installed.
1207
+ const salesChannelId =
1208
+ options.salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1209
+ if (salesChannelId) {
1210
+ const activeCategoryIds = await this._getChannelCategories(salesChannelId);
1211
+ result = result.filter((c: ProductCategory) => activeCategoryIds.has(c.id));
1212
+ }
1213
+
1214
+ // Client-side search filtering
1215
+ if (options.query) {
1216
+ const q = options.query.toLowerCase();
1217
+ result = result.filter(
1218
+ (c: ProductCategory) =>
1219
+ c.name.toLowerCase().includes(q) || c.handle.toLowerCase().includes(q),
1220
+ );
1221
+ }
1222
+
1223
+ return result;
1224
+ } catch (error: any) {
1225
+ this.logger.error('Failed to fetch categories', { error: error.message });
1226
+ throw new ProviderAPIError(
1227
+ `Failed to fetch categories: ${error.message}`,
1228
+ 'medusa',
1229
+ error.status,
1230
+ );
1231
+ }
1232
+ }
1233
+
1234
+ async getCategoryProducts(
1235
+ handle: string,
1236
+ options: CollectionProductsOptions = {},
1237
+ ): Promise<Product[]> {
1238
+ this.logger.debug('getCategoryProducts', { handle, options });
1239
+
1240
+ try {
1241
+ // First get the category to get its ID
1242
+ const category = await this.getCategory(handle);
1243
+ if (!category) {
1244
+ throw new ResourceNotFoundError('Category', handle);
1245
+ }
1246
+
1247
+ // Then get products for this category
1248
+ const categoryQueryParams: any = {
1249
+ category_id: [category.id],
1250
+ order: options.sortKey,
1251
+ limit: options.first ?? 100,
1252
+ offset: options.after ? parseInt(options.after, 10) : undefined,
1253
+ fields:
1254
+ '+variants,+variants.prices,+variants.calculated_prices,+variants.inventory_quantity,+options,+images,+tags',
1255
+ };
1256
+
1257
+ // Scope to storefront sales channel when configured.
1258
+ const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1259
+ if (salesChannelId) {
1260
+ categoryQueryParams.sales_channel_id = [salesChannelId];
1261
+ }
1262
+
1263
+ const { products } = await this.sdk.store.product.list(categoryQueryParams);
1264
+
1265
+ return products.map((p) => transformProduct(p, this.currencyCode));
1266
+ } catch (error: any) {
1267
+ if (error instanceof ResourceNotFoundError) throw error;
1268
+
1269
+ this.logger.error('Failed to fetch category products', { handle, error: error.message });
1270
+ throw new ProviderAPIError(
1271
+ `Failed to fetch category products: ${error.message}`,
1272
+ 'medusa',
1273
+ error.status,
1274
+ );
1275
+ }
1276
+ }
1277
+
1278
+ // ============================================
1279
+ // Cart Operations
1280
+ // ============================================
1281
+
1282
+ async createCart(options?: { regionId?: string }): Promise<Cart> {
1283
+ this.logger.debug('createCart', { regionId: options?.regionId });
1284
+
1285
+ try {
1286
+ const createPayload: any = {};
1287
+
1288
+ // If a regionId is provided, prefer letting Medusa resolve the currency
1289
+ // from the region. Do NOT set `currency_code` when `region_id` is present,
1290
+ // otherwise Medusa may use the provided currency and ignore the region.
1291
+ if (options?.regionId) {
1292
+ createPayload.region_id = options.regionId;
1293
+ } else {
1294
+ createPayload.currency_code = this.currencyCode.toLowerCase();
1295
+ }
1296
+
1297
+ const { cart } = await this.sdk.store.cart.create(createPayload);
1298
+
1299
+ return transformCart(cart);
1300
+ } catch (error: unknown) {
1301
+ const errorMessage = extractErrorMessage(error);
1302
+ this.logger.error('Failed to create cart', { error: errorMessage });
1303
+ throw new ProviderAPIError(
1304
+ `Failed to create cart: ${errorMessage}`,
1305
+ 'medusa',
1306
+ (error as any)?.status || (error as any)?.statusCode,
1307
+ );
1308
+ }
1309
+ }
1310
+
1311
+ async getCart(cartId: string): Promise<Cart | undefined> {
1312
+ this.logger.debug('getCart', { cartId });
1313
+
1314
+ try {
1315
+ const cart = await this.retrieveCartWithLineDetails(cartId);
1316
+
1317
+ if (!cart) {
1318
+ this.logger.debug('Cart not found', { cartId });
1319
+ return undefined;
1320
+ }
1321
+
1322
+ return transformCart(cart);
1323
+ } catch (error: unknown) {
1324
+ // Medusa returns 404 for non-existent carts
1325
+ const status = (error as any)?.status || (error as any)?.statusCode;
1326
+ if (status === 404) {
1327
+ this.logger.debug('Cart not found', { cartId });
1328
+ return undefined;
1329
+ }
1330
+
1331
+ const errorMessage = extractErrorMessage(error);
1332
+ this.logger.error('Failed to fetch cart', { cartId, error: errorMessage });
1333
+ throw new ProviderAPIError(`Failed to fetch cart: ${errorMessage}`, 'medusa', status);
1334
+ }
1335
+ }
1336
+
1337
+ async addToCart(cartId: string, lines: CartLineInput[]): Promise<Cart> {
1338
+ this.logger.debug('addToCart', { cartId, lines });
1339
+
1340
+ try {
1341
+ // Ensure cart exists and has a region_id before attempting to add line items.
1342
+ // If Medusa can't resolve pricing due to missing region/pricing rows, it will throw a 500.
1343
+ const retrieveCtx = { cartId };
1344
+ const retrieveResult = await this.withRetry(
1345
+ async () => this.sdk.store.cart.retrieve(cartId),
1346
+ 'retrieveCartBeforeAdd',
1347
+ retrieveCtx,
1348
+ );
1349
+
1350
+ console.log('retrieveResult =', JSON.stringify(retrieveResult, null, 2))
1351
+
1352
+ const backendCart = (retrieveResult as any)?.cart;
1353
+ if (!backendCart) {
1354
+ this.logger.warn('Cart not found before addToCart', { cartId });
1355
+ throw new ProviderAPIError('Cart not found. Please refresh and try again.', 'medusa', 404);
1356
+ }
1357
+
1358
+ console.log('backendCart =', JSON.stringify(backendCart, null, 2))
1359
+
1360
+ if (!backendCart.region_id) {
1361
+ this.logger.warn('Attempted to add item to cart with no region_id', { cartId });
1362
+ throw new ProviderAPIError(
1363
+ 'Cart does not have a region set. Please select a region or recreate your cart and try again.',
1364
+ 'medusa',
1365
+ 400,
1366
+ );
1367
+ }
1368
+
1369
+ // Add items one by one (Medusa SDK doesn't support batch add)
1370
+ for (const line of lines) {
1371
+ this.logger.debug('createLineItem payload', {
1372
+ cartId,
1373
+ variant_id: line.merchandiseId,
1374
+ quantity: line.quantity,
1375
+ cartRegion: backendCart.region_id,
1376
+ });
1377
+
1378
+ // Pre-check: ensure variant has pricing for the cart's region/currency to avoid server 500
1379
+ try {
1380
+ // The Medusa SDK may not expose a direct productVariant.retrieve endpoint in this version.
1381
+ // Use product.list filtered by region to locate the variant and request region-aware prices.
1382
+ const variantCheckParams: any = {
1383
+ limit: 200,
1384
+ fields: '+variants,+variants.calculated_price,+variants.prices,+variants.money_amounts',
1385
+ region_id: backendCart.region_id,
1386
+ };
1387
+
1388
+ // Scope to storefront sales channel when configured.
1389
+ const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1390
+ if (salesChannelId) {
1391
+ variantCheckParams.sales_channel_id = [salesChannelId];
1392
+ }
1393
+
1394
+ const plist = await this.sdk.store.product.list(variantCheckParams);
1395
+ const products = (plist as any)?.products || [];
1396
+ let foundVariant: any | undefined;
1397
+ for (const p of products) {
1398
+ if (Array.isArray(p.variants)) {
1399
+ const v = p.variants.find((vv: any) => vv.id === line.merchandiseId);
1400
+ if (v) {
1401
+ foundVariant = v;
1402
+ break;
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ if (!foundVariant) {
1408
+ this.logger.warn('Variant not found in product.list for pricing check', {
1409
+ variantId: line.merchandiseId,
1410
+ cartRegion: backendCart.region_id,
1411
+ });
1412
+ throw new ProviderAPIError(
1413
+ 'Product or variant not found. Please refresh and try again.',
1414
+ 'medusa',
1415
+ 404,
1416
+ );
1417
+ }
1418
+
1419
+ console.log('Found Variant:', JSON.stringify(foundVariant, null, 2));
1420
+
1421
+ const variant = foundVariant;
1422
+ // Prefer calculated_price when present
1423
+ let hasPriceForRegion = false;
1424
+ if (
1425
+ variant?.calculated_price &&
1426
+ (variant.calculated_price.calculated_amount ?? null) !== null
1427
+ ) {
1428
+ const calcCurrency = (variant.calculated_price.currency_code || '').toLowerCase();
1429
+ const cartCurrency = (
1430
+ backendCart.currency_code ||
1431
+ this.currencyCode ||
1432
+ ''
1433
+ ).toLowerCase();
1434
+
1435
+ console.log('calcCurrency =', calcCurrency);
1436
+ console.log('cartCurrency =', cartCurrency);
1437
+ console.log('this.currencyCode =', this.currencyCode)
1438
+
1439
+ if (
1440
+ !cartCurrency ||
1441
+ calcCurrency === cartCurrency ||
1442
+ calcCurrency === this.currencyCode.toLowerCase()
1443
+ ) {
1444
+ hasPriceForRegion = true;
1445
+ }
1446
+ }
1447
+
1448
+ if (!hasPriceForRegion) {
1449
+ const prices = variant?.prices || variant?.money_amounts || [];
1450
+ hasPriceForRegion =
1451
+ Array.isArray(prices) &&
1452
+ prices.some((p: any) => {
1453
+ return (
1454
+ p.region_id === backendCart.region_id ||
1455
+ p.currency_code === backendCart.currency_code ||
1456
+ p.currency_code === this.currencyCode ||
1457
+ p.currency === this.currencyCode
1458
+ );
1459
+ });
1460
+ }
1461
+
1462
+ if (!hasPriceForRegion) {
1463
+ const prices = variant?.prices || variant?.money_amounts || [];
1464
+ this.logger.warn('Variant missing price for cart region (product.list)', {
1465
+ variantId: line.merchandiseId,
1466
+ cartRegion: backendCart.region_id,
1467
+ prices,
1468
+ });
1469
+ throw new ProviderAPIError(
1470
+ 'This product does not have pricing configured for your selected region. Please try a different region or contact support.',
1471
+ 'medusa',
1472
+ 400,
1473
+ );
1474
+ }
1475
+ } catch (variantErr) {
1476
+ this.logger.error('Variant price check failed', {
1477
+ variantId: line.merchandiseId,
1478
+ error: extractErrorMessage(variantErr),
1479
+ });
1480
+ // Surface a clear error to avoid a 500 from backend when possible
1481
+ throw new ProviderAPIError(
1482
+ 'Unable to verify variant pricing before adding to cart. Please try again later.',
1483
+ 'medusa',
1484
+ 500,
1485
+ );
1486
+ }
1487
+
1488
+ const result = await this.sdk.store.cart.createLineItem(cartId, {
1489
+ variant_id: line.merchandiseId,
1490
+ quantity: line.quantity,
1491
+ });
1492
+ void result;
1493
+ }
1494
+
1495
+ const cart = await this.retrieveCartWithLineDetails(cartId);
1496
+ return transformCart(cart);
1497
+ } catch (error: unknown) {
1498
+ const errorMessage = extractErrorMessage(error);
1499
+ const errorDetails: any = { cartId, lines, message: errorMessage };
1500
+
1501
+ if (typeof error === 'object' && error !== null) {
1502
+ const err = error as any;
1503
+ if (err.status) errorDetails.status = err.status;
1504
+ if (err.statusCode) errorDetails.statusCode = err.statusCode;
1505
+ if (err.code) errorDetails.code = err.code;
1506
+ // If SDK exposes response body, include it for diagnostics
1507
+ if (err.response) {
1508
+ errorDetails.backendResponse = err.response.data ?? err.response;
1509
+ }
1510
+ // Fallback: include entire error for logs
1511
+ try {
1512
+ errorDetails.fullError = JSON.stringify(err, Object.getOwnPropertyNames(err));
1513
+ } catch (e) {
1514
+ // ignore stringify errors
1515
+ }
1516
+ }
1517
+
1518
+ this.logger.error('Failed to add to cart', errorDetails);
1519
+
1520
+ const status = (error as any)?.status || (error as any)?.statusCode;
1521
+ let userMessage = errorMessage;
1522
+
1523
+ if (status === 500) {
1524
+ userMessage =
1525
+ 'Unable to add product to cart. This product may not have pricing configured for your selected region. Please try selecting a different region or contact support.';
1526
+ } else if (status === 404) {
1527
+ userMessage = 'Product or cart not found. Please refresh the page and try again.';
1528
+ } else if (status === 400 || status === 422) {
1529
+ userMessage = 'Invalid product configuration. This product may be unavailable.';
1530
+ }
1531
+
1532
+ throw new ProviderAPIError(userMessage, 'medusa', status);
1533
+ }
1534
+ }
1535
+
1536
+
1537
+ async removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
1538
+ this.logger.debug('removeFromCart', { cartId, lineIds });
1539
+
1540
+ try {
1541
+ // Medusa removes line items one at a time
1542
+ for (const lineId of lineIds) {
1543
+ await this.sdk.store.cart.deleteLineItem(cartId, lineId);
1544
+ }
1545
+
1546
+ const cart = await this.retrieveCartWithLineDetails(cartId);
1547
+ return transformCart(cart);
1548
+ } catch (error: unknown) {
1549
+ const errorMessage = extractErrorMessage(error);
1550
+ this.logger.error('Failed to remove from cart', { cartId, lineIds, error: errorMessage });
1551
+ throw new ProviderAPIError(
1552
+ `Failed to remove from cart: ${errorMessage}`,
1553
+ 'medusa',
1554
+ (error as any)?.status || (error as any)?.statusCode,
1555
+ );
1556
+ }
1557
+ }
1558
+
1559
+ async updateCart(cartId: string, lines: CartLineUpdate[]): Promise<Cart> {
1560
+ this.logger.debug('updateCart', { cartId, lines });
1561
+
1562
+ try {
1563
+ // Medusa updates line items one at a time
1564
+ for (const line of lines) {
1565
+ await this.sdk.store.cart.updateLineItem(cartId, line.id, {
1566
+ quantity: line.quantity,
1567
+ });
1568
+ }
1569
+
1570
+ const cart = await this.retrieveCartWithLineDetails(cartId);
1571
+ return transformCart(cart);
1572
+ } catch (error: unknown) {
1573
+ const errorMessage = extractErrorMessage(error);
1574
+ this.logger.error('Failed to update cart', { cartId, error: errorMessage });
1575
+ throw new ProviderAPIError(
1576
+ `Failed to update cart: ${errorMessage}`,
1577
+ 'medusa',
1578
+ (error as any)?.status || (error as any)?.statusCode,
1579
+ );
1580
+ }
1581
+ }
1582
+
1583
+ /**
1584
+ * Get available regions from Medusa
1585
+ * Returns region information including id, name, and currency
1586
+ */
1587
+ async getRegions(): Promise<
1588
+ Array<{
1589
+ id: string;
1590
+ name: string;
1591
+ currency_code: string;
1592
+ countries?: Array<{ id: string; iso_2: string; name: string; display_name?: string }>;
1593
+ }>
1594
+ > {
1595
+ this.logger.debug('getRegions');
1596
+
1597
+ try {
1598
+ const { regions } = await this.sdk.store.region.list();
1599
+
1600
+ return regions.map((region: any) => ({
1601
+ id: region.id,
1602
+ name: region.name,
1603
+ currency_code: region.currency_code,
1604
+ countries: region.countries,
1605
+ }));
1606
+ } catch (error: unknown) {
1607
+ const errorMessage = extractErrorMessage(error);
1608
+ this.logger.error('Failed to fetch regions', { error: errorMessage });
1609
+ throw new ProviderAPIError(
1610
+ `Failed to fetch regions: ${errorMessage}`,
1611
+ 'medusa',
1612
+ (error as any)?.status || (error as any)?.statusCode,
1613
+ );
1614
+ }
1615
+ }
1616
+
1617
+ // ============================================
1618
+ // Payment Operations
1619
+ // ============================================
1620
+
1621
+ // ============================================
1622
+ // Order Operations
1623
+ // ============================================
1624
+
1625
+ /**
1626
+ * Get all orders for a customer (storefront).
1627
+ *
1628
+ * Note: This retrieves orders from the storefront API (store.order.list),
1629
+ * which returns orders only for the authenticated customer in the JWT.
1630
+ *
1631
+ * @param options - Order query options (supports callbacks for custom backends)
1632
+ * @returns Array of orders for the authenticated customer
1633
+ *
1634
+ * @example
1635
+ * ```typescript
1636
+ * const orders = await provider.getOrders();
1637
+ * console.log(`Found ${orders.length} orders`);
1638
+ * ```
1639
+ */
1640
+ async getOrders(options: GetOrdersOptions = {}): Promise<Order[]> {
1641
+ const { customFetcher, salesChannelId } = options;
1642
+
1643
+ // If custom fetcher provided, use it instead of default SDK logic
1644
+ if (customFetcher) {
1645
+ this.logger.debug('Using custom fetcher for orders');
1646
+ return customFetcher(options);
1647
+ }
1648
+
1649
+ this.logger.info('Getting orders for authenticated customer');
1650
+
1651
+ try {
1652
+ const result = await this.withRetry(
1653
+ async () =>
1654
+ this.sdk.store.order.list({
1655
+ // Medusa v2 JS-SDK automatically includes authorization header if JWT is present
1656
+ fields: '+items,+items.variant,+items.variant.product,+shipping_address,+billing_address,+customer',
1657
+ }),
1658
+ 'getOrders',
1659
+ {},
1660
+ );
1661
+
1662
+ const orders = result.orders ?? [];
1663
+ this.logger.debug(`Found ${orders.length} orders for customer`);
1664
+
1665
+ const transformed: Order[] = orders.map((order: any) => transformOrder(order));
1666
+
1667
+ // Multi-tenant: filter by sales channel if requested
1668
+ const channelId = salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1669
+ if (channelId) {
1670
+ const scoped = filterByChannel<Order>(transformed, channelId);
1671
+ this.logger.debug(
1672
+ `Channel scoping: ${scoped.length}/${transformed.length} orders match channel ${channelId}`,
1673
+ );
1674
+ return scoped;
1675
+ }
1676
+
1677
+ return transformed;
1678
+ } catch (error) {
1679
+ const status = (error as any)?.status || (error as any)?.statusCode;
1680
+ const errorMessage = extractErrorMessage(error);
1681
+
1682
+ this.logger.error('Error getting orders', { error: errorMessage });
1683
+ throw new ProviderAPIError(
1684
+ `Failed to fetch orders: ${errorMessage}`,
1685
+ 'medusa',
1686
+ status,
1687
+ );
1688
+ }
1689
+ }
1690
+
1691
+ /**
1692
+ * Get a single order by ID
1693
+ *
1694
+ * @param orderId - Order ID
1695
+ * @returns Order or undefined if not found
1696
+ *
1697
+ * @example
1698
+ * ```typescript
1699
+ * const order = await provider.getOrder('order_123');
1700
+ * if (order) {
1701
+ * console.log(`Order ${order.orderNumber} - ${order.status}`);
1702
+ * }
1703
+ * ```
1704
+ */
1705
+ async getOrder(orderId: string): Promise<Order | undefined> {
1706
+ this.logger.info('Getting order', orderId);
1707
+
1708
+ if (!orderId) {
1709
+ throw new ProviderAPIError('Order ID is required', 'medusa');
1710
+ }
1711
+
1712
+ try {
1713
+ const { order } = await this.withRetry(
1714
+ async () =>
1715
+ this.sdk.store.order.retrieve(orderId, {
1716
+ fields: '+items,+items.variant,+items.variant.product,+shipping_address,+billing_address,+customer',
1717
+ }),
1718
+ 'getOrder',
1719
+ { orderId },
1720
+ );
1721
+
1722
+ if (!order) {
1723
+ this.logger.debug(`Order not found: ${orderId}`);
1724
+ return undefined;
1725
+ }
1726
+
1727
+ this.logger.debug(`Retrieved order: ${order.display_id ?? orderId}`);
1728
+
1729
+ return transformOrder(order);
1730
+ } catch (error: unknown) {
1731
+ const status = (error as any)?.status || (error as any)?.statusCode;
1732
+ if (status === 404) {
1733
+ this.logger.debug(`Order not found: ${orderId}`);
1734
+ return undefined;
1735
+ }
1736
+
1737
+ const errorMessage = extractErrorMessage(error);
1738
+ this.logger.error('Failed to fetch order', { orderId, error: errorMessage });
1739
+ throw new ProviderAPIError(`Failed to fetch order: ${errorMessage}`, 'medusa', status);
1740
+ }
1741
+ }
1742
+
1743
+ // ============================================
1744
+ // Customer Operations
1745
+ // ============================================
1746
+
1747
+ /**
1748
+ * List customers with optional filtering and pagination.
1749
+ *
1750
+ * Uses the Medusa admin API — requires admin-level JWT.
1751
+ *
1752
+ * @param options - Query options (search, sort, pagination)
1753
+ * @returns Array of customers
1754
+ *
1755
+ * @example
1756
+ * ```typescript
1757
+ * const customers = await provider.getCustomers({ search: 'john', limit: 20, offset: 0 });
1758
+ * ```
1759
+ */
1760
+ async getCustomers(options: GetCustomersOptions = {}): Promise<Customer[]> {
1761
+ this.logger.info('Getting customers', options);
1762
+
1763
+ const { search, limit = 20, offset = 0, sortBy = 'createdAt', salesChannelId, customFetcher } = options;
1764
+
1765
+ // If custom fetcher provided, use it instead of default SDK logic
1766
+ if (customFetcher) {
1767
+ this.logger.debug('Using custom fetcher for customers');
1768
+ return customFetcher(options);
1769
+ }
1770
+
1771
+ const orderMap: Record<string, string> = {
1772
+ createdAt: 'created_at',
1773
+ email: 'email',
1774
+ firstName: 'first_name',
1775
+ };
1776
+
1777
+ try {
1778
+ // Build filters object for multi-tenant scoping
1779
+ const filters: Record<string, any> = {};
1780
+ if (salesChannelId) {
1781
+ filters.metadata = { sales_channel_id: salesChannelId };
1782
+ this.logger.debug(`Filtering customers by salesChannelId: ${salesChannelId}`);
1783
+ }
1784
+
1785
+ const result = await this.makeAdminRequest(async () => {
1786
+ return (this.sdkAdmin as any).admin.customer.list({
1787
+ q: search || undefined,
1788
+ limit,
1789
+ offset,
1790
+ order: orderMap[sortBy] ?? 'created_at',
1791
+ ...(Object.keys(filters).length > 0 && { filters }),
1792
+ });
1793
+ });
1794
+
1795
+ const customers = result?.customers ?? [];
1796
+ this.logger.debug(`Found ${customers.length} customers`);
1797
+
1798
+ const transformed: Customer[] = customers.map((c: any) => transformCustomer(c));
1799
+
1800
+ return transformed;
1801
+ } catch (error) {
1802
+ const status = (error as any)?.status || (error as any)?.statusCode;
1803
+ const errorMessage = extractErrorMessage(error);
1804
+ this.logger.error('Error getting customers', { error: errorMessage });
1805
+ throw new ProviderAPIError(`Failed to fetch customers: ${errorMessage}`, 'medusa', status);
1806
+ }
1807
+ }
1808
+
1809
+ /**
1810
+ * Get a single customer by ID.
1811
+ *
1812
+ * Uses the Medusa admin API.
1813
+ *
1814
+ * @param customerId - Customer ID
1815
+ * @returns Customer or undefined if not found
1816
+ */
1817
+ async getCustomer(customerId: string): Promise<Customer | undefined> {
1818
+ this.logger.info('Getting customer', customerId);
1819
+
1820
+ if (!customerId) {
1821
+ throw new ProviderAPIError('Customer ID is required', 'medusa');
1822
+ }
1823
+
1824
+ try {
1825
+ const result = await this.makeAdminRequest(async () => {
1826
+ return (this.sdkAdmin as any).admin.customer.retrieve(customerId);
1827
+ });
1828
+
1829
+ if (!result?.customer) {
1830
+ return undefined;
1831
+ }
1832
+
1833
+ return transformCustomer(result.customer);
1834
+ } catch (error) {
1835
+ const status = (error as any)?.status || (error as any)?.statusCode;
1836
+ if (status === 404) return undefined;
1837
+ const errorMessage = extractErrorMessage(error);
1838
+ this.logger.error('Failed to fetch customer', { customerId, error: errorMessage });
1839
+ throw new ProviderAPIError(`Failed to fetch customer: ${errorMessage}`, 'medusa', status);
1840
+ }
1841
+ }
1842
+
1843
+ /**
1844
+ * Update customer information.
1845
+ *
1846
+ * Uses the Medusa admin API.
1847
+ *
1848
+ * @param customerId - Customer ID
1849
+ * @param data - Fields to update
1850
+ * @returns Updated customer
1851
+ */
1852
+ async updateCustomer(
1853
+ customerId: string,
1854
+ data: Partial<Omit<Customer, 'id' | 'createdAt'>>,
1855
+ ): Promise<Customer> {
1856
+ this.logger.info('Updating customer', { customerId, fields: Object.keys(data) });
1857
+
1858
+ if (!customerId) {
1859
+ throw new ProviderAPIError('Customer ID is required', 'medusa');
1860
+ }
1861
+
1862
+ // Transform Thor camelCase → Medusa snake_case
1863
+ const payload: Record<string, unknown> = {};
1864
+ if (data.firstName !== undefined) payload.first_name = data.firstName;
1865
+ if (data.lastName !== undefined) payload.last_name = data.lastName;
1866
+ if (data.phone !== undefined) payload.phone = data.phone ?? null;
1867
+ if (data.email !== undefined) payload.email = data.email;
1868
+ if (data.metadata !== undefined) payload.metadata = data.metadata;
1869
+
1870
+ try {
1871
+ const result = await this.makeAdminRequest(async () => {
1872
+ return (this.sdkAdmin as any).admin.customer.update(customerId, payload);
1873
+ });
1874
+
1875
+ return transformCustomer(result.customer);
1876
+ } catch (error) {
1877
+ const status = (error as any)?.status || (error as any)?.statusCode;
1878
+ const errorMessage = extractErrorMessage(error);
1879
+ this.logger.error('Failed to update customer', { customerId, error: errorMessage });
1880
+ throw new ProviderAPIError(`Failed to update customer: ${errorMessage}`, 'medusa', status);
1881
+ }
1882
+ }
1883
+
1884
+ /**
1885
+ * Get orders placed by a specific customer (admin view).
1886
+ *
1887
+ * @param customerId - Customer ID
1888
+ * @param limit - Max number of orders (default: 10)
1889
+ * @returns Array of orders
1890
+ */
1891
+ async getCustomerOrders(customerId: string, limit = 10): Promise<Order[]> {
1892
+ this.logger.info('Getting orders for customer (admin)', { customerId, limit });
1893
+
1894
+ if (!customerId) {
1895
+ throw new ProviderAPIError('Customer ID is required', 'medusa');
1896
+ }
1897
+
1898
+ if (!this.isAdminConfigured) {
1899
+ this.logger.warn('getCustomerOrders: adminConfig not provided, returning empty result');
1900
+ return [];
1901
+ }
1902
+
1903
+ try {
1904
+ // Use makeAdminRequest to ensure proper admin authentication.
1905
+ // withRetry() does NOT inject admin auth — it would send unauthenticated
1906
+ // requests to admin endpoints.
1907
+ return await this.makeAdminRequest(async () => {
1908
+ const result = await (this.sdkAdmin as any).admin.order.list({
1909
+ customer_id: customerId,
1910
+ limit,
1911
+ order: '-created_at',
1912
+ fields: '+items,+items.variant,+items.variant.product,+shipping_address',
1913
+ });
1914
+
1915
+ const orders = result?.orders ?? [];
1916
+ this.logger.debug(`Found ${orders.length} orders for customer ${customerId}`);
1917
+
1918
+ return orders.map((o: any) => transformOrder(o));
1919
+ });
1920
+ } catch (error) {
1921
+ const status = (error as any)?.status || (error as any)?.statusCode;
1922
+ const errorMessage = extractErrorMessage(error);
1923
+ this.logger.error('Failed to fetch customer orders', { customerId, error: errorMessage });
1924
+ throw new ProviderAPIError(`Failed to fetch customer orders: ${errorMessage}`, 'medusa', status);
1925
+ }
1926
+ }
1927
+
1928
+ // ============================================
1929
+ // Admin Customer Operations
1930
+ // ============================================
1931
+
1932
+ /**
1933
+ * List customers for admin dashboard.
1934
+ *
1935
+ * Uses makeAdminRequest() to ensure proper admin authentication.
1936
+ * Unlike getCustomers() (which currently lacks admin auth), this method
1937
+ * properly awaits admin initialization and authenticates via SDK admin session.
1938
+ *
1939
+ * Gracefully degrades to empty result when adminConfig is not provided.
1940
+ *
1941
+ * @param options - Query options (search, limit, offset, salesChannelId)
1942
+ * @returns Object with customers array and count
1943
+ */
1944
+ async getAdminCustomers(options?: {
1945
+ search?: string;
1946
+ limit?: number;
1947
+ offset?: number;
1948
+ salesChannelId?: string;
1949
+ }): Promise<{ customers: Customer[]; count: number }> {
1950
+ if (!this.isAdminConfigured) {
1951
+ this.logger.warn('getAdminCustomers: adminConfig not provided, returning empty result');
1952
+ return { customers: [], count: 0 };
1953
+ }
1954
+
1955
+ const limit = Math.min(options?.limit ?? 20, 1000);
1956
+ const offset = options?.offset ?? 0;
1957
+
1958
+ this.logger.info('Getting admin customers', {
1959
+ limit,
1960
+ offset,
1961
+ search: options?.search,
1962
+ salesChannelId: options?.salesChannelId,
1963
+ });
1964
+
1965
+ try {
1966
+ return await this.makeAdminRequest(async () => {
1967
+ const result = await (this.sdkAdmin as any).admin.customer.list({
1968
+ limit,
1969
+ offset,
1970
+ order: '-created_at',
1971
+ ...(options?.search && { q: options.search }),
1972
+ });
1973
+
1974
+ const customers = result?.customers ?? [];
1975
+ const count = result?.count ?? customers.length;
1976
+
1977
+ this.logger.debug(`Admin customers: fetched ${customers.length} (backend count: ${count})`);
1978
+
1979
+ const transformed: Customer[] = customers.map((c: any) => transformCustomer(c));
1980
+
1981
+ // NOTE: Medusa customers are global — they are not natively scoped to
1982
+ // a sales channel. filterByChannel checks metadata.sales_channel_id
1983
+ // which Medusa seeds never set → would filter out ALL customers.
1984
+ // For admin dashboard, show all customers.
1985
+ this.logger.debug(`Admin customers: ${transformed.length} returned (backend count: ${count})`);
1986
+
1987
+ return { customers: transformed, count };
1988
+ });
1989
+ } catch (error) {
1990
+ const errorMessage = extractErrorMessage(error);
1991
+ this.logger.error('Failed to get admin customers', { error: errorMessage });
1992
+ throw new ProviderAPIError(
1993
+ `Failed to get admin customers: ${errorMessage}`,
1994
+ 'medusa',
1995
+ (error as any)?.status,
1996
+ );
1997
+ }
1998
+ }
1999
+
2000
+ /**
2001
+ * Get a single customer by ID for admin dashboard.
2002
+ *
2003
+ * Uses makeAdminRequest() for proper admin authentication.
2004
+ * Returns undefined (not an error) when the customer is not found (404).
2005
+ *
2006
+ * @param customerId - Customer ID
2007
+ * @returns Customer or undefined if not found
2008
+ */
2009
+ async getAdminCustomer(customerId: string): Promise<Customer | undefined> {
2010
+ if (!customerId) {
2011
+ throw new ProviderAPIError('Customer ID is required', 'medusa', 400);
2012
+ }
2013
+ if (!this.isAdminConfigured) {
2014
+ throw new ProviderAPIError(
2015
+ 'getAdminCustomer requires adminConfig. Pass adminConfig to createProvider().',
2016
+ 'medusa',
2017
+ 503,
2018
+ );
2019
+ }
2020
+
2021
+ this.logger.info('Getting admin customer', { customerId });
2022
+
2023
+ try {
2024
+ return await this.makeAdminRequest(async () => {
2025
+ const result = await (this.sdkAdmin as any).admin.customer.retrieve(customerId);
2026
+ if (!result?.customer) return undefined;
2027
+ return transformCustomer(result.customer);
2028
+ });
2029
+ } catch (error) {
2030
+ const status = (error as any)?.status || (error as any)?.statusCode;
2031
+ if (status === 404) return undefined;
2032
+ const errorMessage = extractErrorMessage(error);
2033
+ this.logger.error('Failed to get admin customer', { customerId, error: errorMessage });
2034
+ throw new ProviderAPIError(
2035
+ `Failed to get admin customer: ${errorMessage}`,
2036
+ 'medusa',
2037
+ status,
2038
+ );
2039
+ }
2040
+ }
2041
+
2042
+ // ============================================
2043
+ // Admin Customer Address Operations
2044
+ // ============================================
2045
+
2046
+ /**
2047
+ * Get all addresses for a customer (admin operation).
2048
+ * Uses sdk.admin.customer.listAddresses() for proper admin authentication.
2049
+ *
2050
+ * @param customerId - The customer's ID
2051
+ * @returns Array of addresses
2052
+ */
2053
+ async getAdminCustomerAddresses(customerId: string): Promise<Address[]> {
2054
+ if (!customerId) {
2055
+ throw new ProviderAPIError('Customer ID is required', 'medusa', 400);
2056
+ }
2057
+ if (!this.isAdminConfigured) {
2058
+ throw new ProviderAPIError(
2059
+ 'getAdminCustomerAddresses requires adminConfig.',
2060
+ 'medusa',
2061
+ 503,
2062
+ );
2063
+ }
2064
+
2065
+ this.logger.info('Getting admin customer addresses', { customerId });
2066
+
2067
+ try {
2068
+ return await this.makeAdminRequest(async () => {
2069
+ const result = await (this.sdkAdmin as any).admin.customer.listAddresses(customerId);
2070
+ const addresses = result?.addresses ?? [];
2071
+ return addresses.map((addr: any) => transformAddress(addr));
2072
+ });
2073
+ } catch (error) {
2074
+ const status = (error as any)?.status || (error as any)?.statusCode;
2075
+ if (status === 404) return [];
2076
+ const errorMessage = extractErrorMessage(error);
2077
+ this.logger.error('Failed to get admin customer addresses', { customerId, error: errorMessage });
2078
+ throw new ProviderAPIError(
2079
+ `Failed to get customer addresses: ${errorMessage}`,
2080
+ 'medusa',
2081
+ status,
2082
+ );
2083
+ }
2084
+ }
2085
+
2086
+ // ============================================
2087
+ // Admin Order Operations
2088
+ // ============================================
2089
+
2090
+ /**
2091
+ * Get all orders for admin dashboard listing.
2092
+ *
2093
+ * Unlike getCustomerOrders() (which filters by a specific customerId via
2094
+ * sdk.admin.order.list with storefront JWT), this method:
2095
+ * - Lists ALL orders across the store
2096
+ * - Uses fetchAdminAPI() with proper admin credentials
2097
+ * - Applies multi-tenant channel scoping via filterByChannel()
2098
+ * - Gracefully degrades to empty result when adminConfig is not provided
2099
+ *
2100
+ * IMPORTANT: Do NOT use sdk.admin.order.list() here — it uses storefront
2101
+ * JWT auth which is insufficient for Medusa Admin API.
2102
+ */
2103
+ async getAdminOrders(options?: {
2104
+ search?: string;
2105
+ status?: string | string[];
2106
+ limit?: number;
2107
+ offset?: number;
2108
+ /** Medusa field name for sorting, e.g. 'created_at', 'total' */
2109
+ order?: string;
2110
+ direction?: 'asc' | 'desc';
2111
+ /** Filter by sales channel — uses Medusa native `sales_channel_id` query. */
2112
+ salesChannelId?: string;
2113
+ /**
2114
+ * When true, also fetches draft orders from /admin/draft-orders and merges
2115
+ * them into the result sorted by created_at desc. Draft orders are stored
2116
+ * as a separate entity from regular orders in Medusa v2 and do NOT appear
2117
+ * in GET /admin/orders.
2118
+ */
2119
+ includeDrafts?: boolean;
2120
+ }): Promise<{ orders: Order[]; count: number }> {
2121
+ if (!this.isAdminConfigured) {
2122
+ this.logger.warn('getAdminOrders: adminConfig not provided, returning empty result');
2123
+ return { orders: [], count: 0 };
2124
+ }
2125
+
2126
+ const limit = Math.min(options?.limit ?? 20, 100);
2127
+ const offset = options?.offset ?? 0;
2128
+
2129
+ this.logger.info('Getting admin orders', {
2130
+ limit,
2131
+ offset,
2132
+ search: options?.search,
2133
+ status: options?.status,
2134
+ salesChannelId: options?.salesChannelId,
2135
+ includeDrafts: options?.includeDrafts,
2136
+ });
2137
+
2138
+ try {
2139
+ return await this.makeAdminRequest(async () => {
2140
+ const { orders, count } = await this.sdkAdmin!.admin.order.list({
2141
+ limit,
2142
+ offset,
2143
+ fields:
2144
+ 'id,display_id,email,created_at,status,total,currency_code,' +
2145
+ '+items,+items.variant,' +
2146
+ '+shipping_address,+billing_address,+customer',
2147
+ ...(options?.search && { q: options.search }),
2148
+ // Medusa v2 admin API supports native sales_channel_id filter.
2149
+ ...(options?.salesChannelId && { sales_channel_id: [options.salesChannelId] }),
2150
+ });
2151
+
2152
+ const transformed: Order[] = (orders ?? []).map((o: any) => transformOrder(o));
2153
+
2154
+ this.logger.debug(`Admin orders: ${transformed.length} returned (backend count: ${count})`);
2155
+
2156
+ // In Medusa v2, draft orders are a SEPARATE entity from regular orders.
2157
+ // GET /admin/orders never returns them — you must query GET /admin/draft-orders.
2158
+ // When includeDrafts is requested, merge both lists sorted by created_at desc.
2159
+ if (!options?.includeDrafts) {
2160
+ return { orders: transformed, count };
2161
+ }
2162
+
2163
+ let draftOrders: Order[] = [];
2164
+ let draftCount = 0;
2165
+
2166
+ try {
2167
+ // Fetch all draft orders (typically small number, no pagination needed).
2168
+ // Use a generous limit so we don't miss recently-created ones.
2169
+ const draftResult = await (this.sdkAdmin as any).admin.draftOrder.list({
2170
+ limit: 100,
2171
+ offset: 0,
2172
+ fields:
2173
+ 'id,display_id,email,created_at,status,total,currency_code,' +
2174
+ '+items,+items.variant,' +
2175
+ '+shipping_address,+billing_address,+customer',
2176
+ ...(options?.salesChannelId && { sales_channel_id: [options.salesChannelId] }),
2177
+ });
2178
+
2179
+ // Medusa returns { draft_orders: [...], count: N }
2180
+ const rawDrafts = draftResult?.draft_orders ?? [];
2181
+ draftCount = draftResult?.count ?? rawDrafts.length;
2182
+ draftOrders = rawDrafts.map((o: any) => ({
2183
+ ...transformOrder(o),
2184
+ isDraft: true, // ← Mark as draft order for UI differentiation
2185
+ }));
2186
+
2187
+ this.logger.debug(`Draft orders: ${draftOrders.length} returned (backend count: ${draftCount})`);
2188
+ } catch (draftError) {
2189
+ // Draft orders endpoint may not be available (plugin not installed).
2190
+ // Log warning but don't fail the whole request.
2191
+ this.logger.warn('Failed to fetch draft orders — plugin may not be installed', {
2192
+ error: extractErrorMessage(draftError),
2193
+ });
2194
+ }
2195
+
2196
+ // Merge and sort combined list by created_at descending.
2197
+ const combined = [...transformed, ...draftOrders].sort(
2198
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
2199
+ );
2200
+
2201
+ return { orders: combined, count: count + draftCount };
2202
+ });
2203
+ } catch (error) {
2204
+ const errorMessage = extractErrorMessage(error);
2205
+ this.logger.error('Failed to get admin orders', { error: errorMessage });
2206
+ throw new ProviderAPIError(
2207
+ `Failed to get admin orders: ${errorMessage}`,
2208
+ 'medusa',
2209
+ (error as any)?.status,
2210
+ );
2211
+ }
2212
+ }
2213
+
2214
+ /**
2215
+ * Cancel an order (admin operation).
2216
+ * Uses sdk.admin.order.cancel() for proper SDK integration.
2217
+ * Throws ProviderAPIError if adminConfig is not provided.
2218
+ */
2219
+ async cancelOrder(orderId: string): Promise<Order> {
2220
+ if (!orderId || typeof orderId !== 'string') {
2221
+ throw new ProviderAPIError('Invalid order ID', 'medusa', 400);
2222
+ }
2223
+ if (!this.isAdminConfigured) {
2224
+ throw new ProviderAPIError(
2225
+ 'cancelOrder requires adminConfig. Pass adminConfig to createProvider().',
2226
+ 'medusa',
2227
+ 503,
2228
+ );
2229
+ }
2230
+
2231
+ this.logger.info('Cancelling order', { orderId });
2232
+
2233
+ try {
2234
+ return await this.makeAdminRequest(async () => {
2235
+ const { order } = await this.sdkAdmin!.admin.order.cancel(orderId);
2236
+
2237
+ const transformed = transformOrder(order);
2238
+ this.logger.debug('Order cancelled', { orderId, status: transformed.status });
2239
+ return transformed;
2240
+ });
2241
+ } catch (error) {
2242
+ const errorMessage = extractErrorMessage(error);
2243
+ this.logger.error('Failed to cancel order', { orderId, error: errorMessage });
2244
+ throw new ProviderAPIError(
2245
+ `Failed to cancel order: ${errorMessage}`,
2246
+ 'medusa',
2247
+ (error as any)?.status,
2248
+ );
2249
+ }
2250
+ }
2251
+
2252
+ /**
2253
+ * Create a fulfillment for an order (admin operation).
2254
+ * Uses sdk.admin.order.createFulfillment() for proper SDK integration.
2255
+ * Throws ProviderAPIError if adminConfig is not provided.
2256
+ *
2257
+ * @param items - Optional specific line-items + quantities.
2258
+ * When omitted, Medusa fulfills all unfulfilled items.
2259
+ */
2260
+ async createFulfillment(
2261
+ orderId: string,
2262
+ items?: Array<{ id: string; quantity: number }>,
2263
+ ): Promise<void> {
2264
+ if (!orderId || typeof orderId !== 'string') {
2265
+ throw new ProviderAPIError('Invalid order ID', 'medusa', 400);
2266
+ }
2267
+ if (!this.isAdminConfigured) {
2268
+ throw new ProviderAPIError(
2269
+ 'createFulfillment requires adminConfig. Pass adminConfig to createProvider().',
2270
+ 'medusa',
2271
+ 503,
2272
+ );
2273
+ }
2274
+
2275
+ this.logger.info('Creating fulfillment', { orderId, itemCount: items?.length });
2276
+
2277
+ if (items !== undefined && items.length === 0) {
2278
+ throw new ProviderAPIError(
2279
+ 'Cannot create fulfillment: items array must have at least one item.',
2280
+ 'medusa',
2281
+ 400,
2282
+ );
2283
+ }
2284
+
2285
+ try {
2286
+ await this.makeAdminRequest(async () => {
2287
+ // items is guaranteed to be ≥1 here (empty array rejected above).
2288
+ // When items is undefined, pass items: [] to satisfy the SDK type;
2289
+ // Medusa will fulfill all unfulfilled items in that case.
2290
+ await this.sdkAdmin!.admin.order.createFulfillment(orderId, {
2291
+ items: items ?? [],
2292
+ });
2293
+ this.logger.debug('Fulfillment created', { orderId });
2294
+ });
2295
+ } catch (error) {
2296
+ const errorMessage = extractErrorMessage(error);
2297
+ this.logger.error('Failed to create fulfillment', { orderId, error: errorMessage });
2298
+ throw new ProviderAPIError(
2299
+ `Failed to create fulfillment: ${errorMessage}`,
2300
+ 'medusa',
2301
+ (error as any)?.status,
2302
+ );
2303
+ }
2304
+ }
2305
+
2306
+ /**
2307
+ * Create a refund for an order (admin operation).
2308
+ *
2309
+ * Uses fetchAdminAPI() — NOT sdk.admin.refund.create().
2310
+ * Throws ProviderAPIError if adminConfig is not provided.
2311
+ *
2312
+ * @param amount - Amount in cents. Omit to refund the full order total.
2313
+ * @param reason - Reason string (default: 'admin_refund').
2314
+ */
2315
+ async createRefund(orderId: string, amount?: number, reason?: string): Promise<void> {
2316
+ if (!orderId || typeof orderId !== 'string') {
2317
+ throw new ProviderAPIError('Invalid order ID', 'medusa', 400);
2318
+ }
2319
+ if (!this.isAdminConfigured) {
2320
+ throw new ProviderAPIError(
2321
+ 'createRefund requires adminConfig. Pass adminConfig to createProvider().',
2322
+ 'medusa',
2323
+ 503,
2324
+ );
2325
+ }
2326
+
2327
+ this.logger.info('Creating refund', { orderId, amount, reason });
2328
+
2329
+ try {
2330
+ await this.makeAdminRequest(async () => {
2331
+ // Note: Medusa v2 refunds are done via payment refund, not order refund
2332
+ // For now, fallback to manual request if SDK doesn't have direct order refund
2333
+ // This would need to be updated once we understand the exact refund flow
2334
+ // For now, we could use sdk.admin.claim.create() with type: 'refund'
2335
+ // or direct payment refund via sdk.admin.payment.refund()
2336
+
2337
+ // Placeholder: using makeAdminRequest ensures proper auth
2338
+ // The actual refund endpoint may vary byMedusa version
2339
+ // Future: implement proper refund flow using SDK methods
2340
+ this.logger.warn('createRefund: full SDK integration pending - using minimal implementation');
2341
+ });
2342
+ this.logger.debug('Refund created', { orderId, amount });
2343
+ } catch (error) {
2344
+ const errorMessage = extractErrorMessage(error);
2345
+ this.logger.error('Failed to create refund', { orderId, error: errorMessage });
2346
+ throw new ProviderAPIError(
2347
+ `Failed to create refund: ${errorMessage}`,
2348
+ 'medusa',
2349
+ (error as any)?.status,
2350
+ );
2351
+ }
2352
+ }
2353
+
2354
+ /**
2355
+ * List all regions for admin dashboard (used in Create Order dialog).
2356
+ *
2357
+ * Returns Region[] compatible with CommerceProvider contract.
2358
+ * Maps Medusa's currency_code → currency and provides empty countryCodes array
2359
+ * when country detail is not required for the admin region picker.
2360
+ */
2361
+ async getAdminRegions(): Promise<Region[]> {
2362
+ if (!this.isAdminConfigured) {
2363
+ this.logger.warn('getAdminRegions: adminConfig not provided, returning empty result');
2364
+ return [];
2365
+ }
2366
+
2367
+ try {
2368
+ return await this.makeAdminRequest(async () => {
2369
+ const { regions } = await this.sdkAdmin!.admin.region.list({
2370
+ limit: 100,
2371
+ fields: 'id,name,currency_code,countries',
2372
+ });
2373
+ return (regions ?? []).map((r: any) => ({
2374
+ id: r.id,
2375
+ name: r.name,
2376
+ currency: r.currency_code ?? '',
2377
+ countryCodes: Array.isArray(r.countries)
2378
+ ? r.countries.map((c: any) => c.iso_2 ?? c.iso2 ?? '').filter(Boolean)
2379
+ : [],
2380
+ }));
2381
+ });
2382
+ } catch (error) {
2383
+ const errorMessage = extractErrorMessage(error);
2384
+ this.logger.error('Failed to get admin regions', { error: errorMessage });
2385
+ return [];
2386
+ }
2387
+ }
2388
+
2389
+ /**
2390
+ * Get a single order by ID for admin dashboard detail view.
2391
+ *
2392
+ * Uses makeAdminRequest() for proper admin authentication.
2393
+ * Returns full order with items, addresses, customer.
2394
+ *
2395
+ * @returns Order or undefined if not found
2396
+ */
2397
+ async getAdminOrder(orderId: string): Promise<Order | undefined> {
2398
+ if (!orderId) {
2399
+ throw new ProviderAPIError('Order ID is required', 'medusa', 400);
2400
+ }
2401
+ if (!this.isAdminConfigured) {
2402
+ throw new ProviderAPIError(
2403
+ 'getAdminOrder requires adminConfig.',
2404
+ 'medusa',
2405
+ 503,
2406
+ );
2407
+ }
2408
+
2409
+ this.logger.info('Getting admin order', { orderId });
2410
+
2411
+ try {
2412
+ return await this.makeAdminRequest(async () => {
2413
+ const { order } = await this.sdkAdmin!.admin.order.retrieve(orderId, {
2414
+ fields:
2415
+ 'id,display_id,email,created_at,updated_at,status,' +
2416
+ 'payment_status,fulfillment_status,' +
2417
+ 'total,subtotal,tax_total,shipping_total,discount_total,currency_code,' +
2418
+ '*items,*items.variant,' +
2419
+ '*shipping_address,*billing_address,*customer',
2420
+ });
2421
+ if (!order) return undefined;
2422
+ return transformOrder(order);
2423
+ });
2424
+ } catch (error) {
2425
+ const status = (error as any)?.status || (error as any)?.statusCode;
2426
+ if (status === 404) return undefined;
2427
+ const errorMessage = extractErrorMessage(error);
2428
+ this.logger.error('Failed to get admin order', { orderId, error: errorMessage });
2429
+ throw new ProviderAPIError(
2430
+ `Failed to get admin order: ${errorMessage}`,
2431
+ 'medusa',
2432
+ status,
2433
+ );
2434
+ }
2435
+ }
2436
+
2437
+ /**
2438
+ * Get a single product by ID for admin dashboard detail view.
2439
+ *
2440
+ * Uses makeAdminRequest() for proper admin authentication.
2441
+ * Returns full product with variants and prices.
2442
+ *
2443
+ * @returns AdminProduct or undefined if not found
2444
+ */
2445
+ async getAdminProduct(productId: string): Promise<AdminProduct | undefined> {
2446
+ if (!productId) {
2447
+ throw new ProviderAPIError('Product ID is required', 'medusa', 400);
2448
+ }
2449
+ if (!this.isAdminConfigured) {
2450
+ throw new ProviderAPIError(
2451
+ 'getAdminProduct requires adminConfig.',
2452
+ 'medusa',
2453
+ 503,
2454
+ );
2455
+ }
2456
+
2457
+ this.logger.info('Getting admin product', { productId });
2458
+
2459
+ try {
2460
+ return await this.makeAdminRequest(async () => {
2461
+ const { product } = await this.sdkAdmin!.admin.product.retrieve(productId, {
2462
+ fields:
2463
+ 'id,handle,title,description,status,thumbnail,' +
2464
+ '+variants,+variants.prices,+variants.inventory_quantity,+variants.sku,' +
2465
+ '+variants.options,+variants.options.option,' +
2466
+ '+images,+options',
2467
+ });
2468
+ if (!product) return undefined;
2469
+ return {
2470
+ id: product.id,
2471
+ handle: product.handle,
2472
+ title: product.title,
2473
+ description: (product as any).description ?? undefined,
2474
+ status: product.status,
2475
+ thumbnail: product.thumbnail ?? undefined,
2476
+ images: ((product as any).images ?? []).map((img: any) => img.url).filter(Boolean),
2477
+ variants: ((product as any).variants ?? []).map((v: any) => {
2478
+ // Build a readable title: explicit title > options joined > SKU > "Default"
2479
+ let variantTitle = v.title;
2480
+ if (!variantTitle && Array.isArray(v.options) && v.options.length > 0) {
2481
+ variantTitle = v.options
2482
+ .map((opt: any) => opt.value)
2483
+ .filter(Boolean)
2484
+ .join(' / ');
2485
+ }
2486
+ if (!variantTitle) variantTitle = v.sku || 'Default';
2487
+
2488
+ return {
2489
+ id: v.id,
2490
+ title: variantTitle,
2491
+ sku: v.sku || undefined,
2492
+ quantityAvailable: v.inventory_quantity ?? undefined,
2493
+ prices: (v.prices ?? []).map((pr: any) => ({
2494
+ amount: pr.amount,
2495
+ currencyCode: (pr.currency_code ?? '').toUpperCase(),
2496
+ })),
2497
+ };
2498
+ }),
2499
+ };
2500
+ });
2501
+ } catch (error) {
2502
+ const status = (error as any)?.status || (error as any)?.statusCode;
2503
+ if (status === 404) return undefined;
2504
+ const errorMessage = extractErrorMessage(error);
2505
+ this.logger.error('Failed to get admin product', { productId, error: errorMessage });
2506
+ throw new ProviderAPIError(
2507
+ `Failed to get admin product: ${errorMessage}`,
2508
+ 'medusa',
2509
+ status,
2510
+ );
2511
+ }
2512
+ }
2513
+
2514
+ /**
2515
+ * Create a draft order.
2516
+ *
2517
+ * Uses Medusa v2 `sdk.admin.draftOrder.create()` API.
2518
+ * Draft orders have `is_draft_order = true` and can be converted to regular orders.
2519
+ *
2520
+ * @param params - Draft order parameters
2521
+ * @returns Created draft order as a transformed Order
2522
+ */
2523
+ async createDraftOrder(params: {
2524
+ email: string;
2525
+ regionId: string;
2526
+ salesChannelId?: string;
2527
+ items: Array<{ variantId: string; quantity: number }>;
2528
+ shippingAddress?: {
2529
+ firstName?: string;
2530
+ lastName?: string;
2531
+ address1?: string;
2532
+ city?: string;
2533
+ countryCode?: string;
2534
+ postalCode?: string;
2535
+ };
2536
+ }): Promise<Order> {
2537
+ if (!this.isAdminConfigured) {
2538
+ throw new ProviderAPIError(
2539
+ 'createDraftOrder requires adminConfig.',
2540
+ 'medusa',
2541
+ 503,
2542
+ );
2543
+ }
2544
+
2545
+ this.logger.info('Creating draft order', {
2546
+ email: params.email,
2547
+ regionId: params.regionId,
2548
+ itemCount: params.items.length,
2549
+ });
2550
+
2551
+ try {
2552
+ return await this.makeAdminRequest(async () => {
2553
+ const result = await (this.sdkAdmin as any).admin.draftOrder.create({
2554
+ email: params.email,
2555
+ region_id: params.regionId,
2556
+ ...(params.salesChannelId && { sales_channel_id: params.salesChannelId }),
2557
+ items: params.items.map((item) => ({
2558
+ variant_id: item.variantId,
2559
+ quantity: item.quantity,
2560
+ })),
2561
+ ...(params.shippingAddress && {
2562
+ shipping_address: {
2563
+ first_name: params.shippingAddress.firstName,
2564
+ last_name: params.shippingAddress.lastName,
2565
+ address_1: params.shippingAddress.address1,
2566
+ city: params.shippingAddress.city,
2567
+ country_code: params.shippingAddress.countryCode,
2568
+ postal_code: params.shippingAddress.postalCode,
2569
+ },
2570
+ }),
2571
+ });
2572
+
2573
+ const draftOrder = result?.draft_order;
2574
+ if (!draftOrder) {
2575
+ throw new ProviderAPIError('Draft order creation returned no data', 'medusa', 500);
2576
+ }
2577
+
2578
+ this.logger.debug('Draft order created', { id: draftOrder.id });
2579
+ return transformOrder(draftOrder);
2580
+ });
2581
+ } catch (error) {
2582
+ if (error instanceof ProviderAPIError) throw error;
2583
+ const errorMessage = extractErrorMessage(error);
2584
+ this.logger.error('Failed to create draft order', { error: errorMessage });
2585
+ throw new ProviderAPIError(
2586
+ `Failed to create draft order: ${errorMessage}`,
2587
+ 'medusa',
2588
+ (error as any)?.status,
2589
+ );
2590
+ }
2591
+ }
2592
+
2593
+ /**
2594
+ * Convert a draft order into a regular (pending) order.
2595
+ *
2596
+ * Uses `sdk.admin.draftOrder.convertToOrder()` which maps to
2597
+ * `POST /admin/draft-orders/{id}/convert-to-order`.
2598
+ * Returns the newly created order transformed into `@thorprovider/types.Order`.
2599
+ */
2600
+ async undraftOrder(draftOrderId: string): Promise<Order> {
2601
+ if (!draftOrderId || typeof draftOrderId !== 'string') {
2602
+ throw new ProviderAPIError('Invalid draft order ID', 'medusa', 400);
2603
+ }
2604
+ if (!this.isAdminConfigured) {
2605
+ throw new ProviderAPIError(
2606
+ 'undraftOrder requires adminConfig. Pass adminConfig to createProvider().',
2607
+ 'medusa',
2608
+ 503,
2609
+ );
2610
+ }
2611
+
2612
+ this.logger.info('Converting draft order to order', { draftOrderId });
2613
+
2614
+ try {
2615
+ return await this.makeAdminRequest(async () => {
2616
+ const result = await (this.sdkAdmin as any).admin.draftOrder.convertToOrder(
2617
+ draftOrderId,
2618
+ );
2619
+ const order = result?.order;
2620
+ if (!order) {
2621
+ throw new ProviderAPIError(
2622
+ 'Unexpected response: missing order in convertToOrder result',
2623
+ 'medusa',
2624
+ 500,
2625
+ );
2626
+ }
2627
+ this.logger.debug('Draft order converted to order', { id: order.id });
2628
+ return transformOrder(order);
2629
+ });
2630
+ } catch (error) {
2631
+ if (error instanceof ProviderAPIError) throw error;
2632
+ const errorMessage = extractErrorMessage(error);
2633
+ this.logger.error('Failed to convert draft order', { draftOrderId, error: errorMessage });
2634
+ throw new ProviderAPIError(
2635
+ `Failed to convert draft order: ${errorMessage}`,
2636
+ 'medusa',
2637
+ (error as any)?.status,
2638
+ );
2639
+ }
2640
+ }
2641
+
2642
+ // ============================================
2643
+ // Channel Scope Operations (Multi-Tenant)
2644
+ // ============================================
2645
+
2646
+ /**
2647
+ * Assign a customer to a sales channel via metadata.
2648
+ *
2649
+ * This is the primary mechanism for multi-tenant isolation of entities
2650
+ * that the backend does not natively scope to a sales channel
2651
+ * (e.g. customers in Medusa).
2652
+ *
2653
+ * Writes `sales_channel_id` + audit fields into `customer.metadata`.
2654
+ * After assignment, `getCustomers({ salesChannelId })` will include
2655
+ * this customer in its results.
2656
+ *
2657
+ * @param customerId - Customer to assign
2658
+ * @param channelId - Target sales channel
2659
+ * @param assignedBy - Optional: who triggered the assignment
2660
+ * @returns Updated customer with new metadata
2661
+ *
2662
+ * @example
2663
+ * ```typescript
2664
+ * // Dropshipper creates customer → assign to their channel
2665
+ * const customer = await commerce.assignCustomerToChannel(
2666
+ * 'cus_01HXXX',
2667
+ * process.env.NEXT_PUBLIC_SALES_CHANNEL_ID!,
2668
+ * 'admin@mystore.com',
2669
+ * );
2670
+ * ```
2671
+ */
2672
+ async assignCustomerToChannel(
2673
+ customerId: string,
2674
+ channelId: string,
2675
+ assignedBy?: string,
2676
+ ): Promise<Customer> {
2677
+ validateChannelScopeAssignment(customerId, channelId, assignedBy);
2678
+
2679
+ this.logger.info('Assigning customer to channel', { customerId, channelId, assignedBy });
2680
+
2681
+ // Fetch current metadata to merge (preserves existing keys)
2682
+ const existing = await this.getCustomer(customerId);
2683
+ const currentMeta = existing?.metadata ?? {};
2684
+
2685
+ const scopeMeta = buildChannelScopeMetadata(channelId, assignedBy);
2686
+
2687
+ return this.updateCustomer(customerId, {
2688
+ metadata: { ...currentMeta, ...scopeMeta },
2689
+ });
2690
+ }
2691
+
2692
+ /**
2693
+ * Assign an order to a sales channel via metadata.
2694
+ *
2695
+ * Same pattern as `assignCustomerToChannel` but for orders.
2696
+ * Useful when orders are created through flows that don't automatically
2697
+ * link them to a channel.
2698
+ *
2699
+ * @param orderId - Order to assign
2700
+ * @param channelId - Target sales channel
2701
+ * @param assignedBy - Optional: who triggered the assignment
2702
+ */
2703
+ async assignOrderToChannel(
2704
+ orderId: string,
2705
+ channelId: string,
2706
+ assignedBy?: string,
2707
+ ): Promise<void> {
2708
+ validateChannelScopeAssignment(orderId, channelId, assignedBy);
2709
+
2710
+ this.logger.info('Assigning order to channel', { orderId, channelId, assignedBy });
2711
+
2712
+ const scopeMeta = buildChannelScopeMetadata(channelId, assignedBy);
2713
+
2714
+ if (!this.isAdminConfigured) {
2715
+ throw new ProviderAPIError(
2716
+ 'assignOrderToChannel requires adminConfig. Pass adminConfig.apiKey to createProvider().',
2717
+ 'medusa',
2718
+ 503,
2719
+ );
2720
+ }
2721
+
2722
+ try {
2723
+ await this.makeAdminRequest(async () => {
2724
+ await (this.sdkAdmin as any).admin.order.update(orderId, {
2725
+ metadata: scopeMeta,
2726
+ });
2727
+ });
2728
+ } catch (error) {
2729
+ const status = (error as any)?.status || (error as any)?.statusCode;
2730
+ const errorMessage = extractErrorMessage(error);
2731
+ this.logger.error('Failed to assign order to channel', { orderId, channelId, error: errorMessage });
2732
+ throw new ProviderAPIError(`Failed to assign order to channel: ${errorMessage}`, 'medusa', status);
2733
+ }
2734
+ }
2735
+
2736
+ // ============================================
2737
+ // Payment Operations
2738
+ // ============================================
2739
+
2740
+ /**
2741
+ * Get available payment methods
2742
+ *
2743
+ * Queries Medusa for payment providers available in a specific region.
2744
+ * Region is determined either from options.regionId or from the cart.
2745
+ *
2746
+ * @param options - Payment methods query options (regionId or cartId required)
2747
+ * @returns Array of available payment methods
2748
+ *
2749
+ * @example
2750
+ * ```typescript
2751
+ * // Get payment methods for a region
2752
+ * const methods = await provider.getPaymentMethods({
2753
+ * regionId: 'reg_123',
2754
+ * enabledOnly: true
2755
+ * });
2756
+ *
2757
+ * // Get payment methods for a cart
2758
+ * const methods = await provider.getPaymentMethods({
2759
+ * cartId: 'cart_123'
2760
+ * });
2761
+ * ```
2762
+ */
2763
+ async getPaymentMethods(options: PaymentMethodsOptions): Promise<PaymentMethod[]> {
2764
+ this.logger.info('Getting payment methods', options);
2765
+
2766
+ try {
2767
+ const methods = await getPaymentMethods(this.sdk, options);
2768
+ this.logger.debug(`Found ${methods.length} payment methods`);
2769
+ return methods;
2770
+ } catch (error) {
2771
+ this.logger.error('Error getting payment methods', error);
2772
+ throw error; // Re-throw as it's already wrapped by getPaymentMethods
2773
+ }
2774
+ }
2775
+
2776
+ // ============================================
2777
+ // Checkout Operations
2778
+ // ============================================
2779
+
2780
+ /**
2781
+ * Complete checkout and create order from cart
2782
+ *
2783
+ * Finalizes the checkout process and converts the cart into an order.
2784
+ * Cart must have shipping address, shipping method, and payment session.
2785
+ *
2786
+ * @param cartId - Cart ID to checkout
2787
+ * @returns Created order
2788
+ * @throws {ProviderAPIError} If checkout fails
2789
+ */
2790
+ async completeCheckout(cartId: string): Promise<Order> {
2791
+ this.logger.debug('completeCheckout', { cartId });
2792
+
2793
+ try {
2794
+ return await this.withRetry(
2795
+ async () => {
2796
+ // Medusa v2: POST /store/carts/:id/complete
2797
+ const { order } = (await this.sdk.store.cart.complete(cartId)) as any;
2798
+
2799
+ if (!order) {
2800
+ throw new ProviderAPIError('Checkout completed but no order returned', 'medusa', 500);
2801
+ }
2802
+
2803
+ this.logger.info('Checkout completed successfully', {
2804
+ orderId: order.id,
2805
+ displayId: order.display_id,
2806
+ });
2807
+
2808
+ return transformOrder(order);
2809
+ },
2810
+ 'completeCheckout',
2811
+ { cartId },
2812
+ );
2813
+ } catch (error: any) {
2814
+ this.logger.error('Failed to complete checkout', { cartId, error: error.message });
2815
+ throw new ProviderAPIError(
2816
+ `Failed to complete checkout: ${error.message}`,
2817
+ 'medusa',
2818
+ error.status,
2819
+ );
2820
+ }
2821
+ }
2822
+
2823
+ /**
2824
+ * Get available shipping methods for cart
2825
+ *
2826
+ * Fetches shipping options based on cart region and shipping address.
2827
+ *
2828
+ * @param cartId - Cart ID
2829
+ * @returns List of shipping methods with prices
2830
+ * @throws {ProviderAPIError} If cart not found or region not configured
2831
+ */
2832
+ async getShippingMethods(cartId: string): Promise<import('@thorprovider/types').ShippingMethod[]> {
2833
+ this.logger.info('🚚 [DEBUG] getShippingMethods called', { cartId });
2834
+
2835
+ try {
2836
+ return await this.withRetry(
2837
+ async () => {
2838
+ // Get raw cart to access region
2839
+ this.logger.info('🚚 [DEBUG] Step 1: Retrieving cart from Medusa...', { cartId });
2840
+ const cartResponse = (await this.sdk.store.cart.retrieve(cartId)) as any;
2841
+ const { cart } = cartResponse;
2842
+
2843
+ // Log raw cart response for debugging
2844
+ this.logger.info('🚚 [DEBUG] Step 2: Cart retrieved from backend', {
2845
+ cartId,
2846
+ hasCart: !!cart,
2847
+ cartKeys: cart ? Object.keys(cart) : [],
2848
+ regionId: cart?.region_id,
2849
+ currencyCode: cart?.currency_code,
2850
+ shippingAddress: cart?.shipping_address
2851
+ ? {
2852
+ country: cart.shipping_address.country_code,
2853
+ postal: cart.shipping_address.postal_code,
2854
+ }
2855
+ : null,
2856
+ });
2857
+
2858
+ if (!cart) {
2859
+ this.logger.error('🚚 [DEBUG] ERROR: Cart not found in response', {
2860
+ cartId,
2861
+ cartResponse,
2862
+ });
2863
+ throw new ResourceNotFoundError('Cart', cartId);
2864
+ }
2865
+
2866
+ if (!cart.region_id) {
2867
+ this.logger.error('🚚 [DEBUG] ERROR: Cart has no region_id', {
2868
+ cartId,
2869
+ cart: {
2870
+ id: cart.id,
2871
+ region_id: cart.region_id,
2872
+ region: cart.region,
2873
+ allKeys: Object.keys(cart),
2874
+ },
2875
+ });
2876
+ throw new ProviderAPIError('Cart has no region assigned', 'medusa', 400);
2877
+ }
2878
+
2879
+ // Fetch shipping options for cart
2880
+ // Medusa v2 endpoint: GET /store/shipping-options?cart_id=...
2881
+ this.logger.info('🚚 [DEBUG] Step 3: Fetching shipping options from Medusa...', {
2882
+ cartId,
2883
+ regionId: cart.region_id,
2884
+ endpoint: `${this.baseUrl}store/shipping-options?cart_id=${cartId}`,
2885
+ note: 'About to fetch shipping options (SDK when available, otherwise HTTP)',
2886
+ });
2887
+
2888
+ let shippingResponse: any;
2889
+ let shipping_options: any;
2890
+
2891
+ try {
2892
+ const storeAny = this.sdk.store as any;
2893
+
2894
+ // Prefer SDK if present (newer SDK versions)
2895
+ if (storeAny?.shippingOption?.list) {
2896
+ shippingResponse = (await storeAny.shippingOption.list({
2897
+ cart_id: cartId,
2898
+ })) as any;
2899
+ } else {
2900
+ // Fallback for SDK versions that don't expose shippingOption typings/APIs
2901
+ const baseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
2902
+ const url = new URL('store/shipping-options', baseUrl);
2903
+ url.searchParams.set('cart_id', cartId);
2904
+
2905
+ const response = await fetchWithRetry(
2906
+ url.toString(),
2907
+ {
2908
+ method: 'GET',
2909
+ headers: {
2910
+ 'x-publishable-api-key': this.publishableKey,
2911
+ 'content-type': 'application/json',
2912
+ },
2913
+ },
2914
+ this.retryConfig,
2915
+ );
2916
+
2917
+ shippingResponse = (await response.json()) as any;
2918
+ }
2919
+
2920
+ shipping_options = shippingResponse.shipping_options;
2921
+ } catch (sdkError: any) {
2922
+ // Log detailed error from Medusa backend
2923
+ this.logger.error(
2924
+ '🚚 [DEBUG] ERROR: Medusa SDK threw error when fetching shipping options',
2925
+ {
2926
+ cartId,
2927
+ regionId: cart.region_id,
2928
+ errorType: sdkError?.constructor?.name,
2929
+ errorMessage: sdkError?.message,
2930
+ errorStatus: sdkError?.status,
2931
+ errorCode: sdkError?.code,
2932
+ errorResponse: sdkError?.response
2933
+ ? JSON.stringify(sdkError.response, null, 2)
2934
+ : 'N/A',
2935
+ errorBody: sdkError?.body ? JSON.stringify(sdkError.body, null, 2) : 'N/A',
2936
+ fullError: JSON.stringify(sdkError, Object.getOwnPropertyNames(sdkError), 2),
2937
+ },
2938
+ );
2939
+ throw sdkError;
2940
+ }
2941
+
2942
+ // Log raw shipping options response
2943
+ this.logger.info('🚚 [DEBUG] Step 4: Shipping options received from backend', {
2944
+ cartId,
2945
+ totalOptions: shipping_options?.length || 0,
2946
+ rawResponse: JSON.stringify(shippingResponse, null, 2),
2947
+ firstOption: shipping_options?.[0]
2948
+ ? {
2949
+ id: shipping_options[0].id,
2950
+ name: shipping_options[0].name,
2951
+ allKeys: Object.keys(shipping_options[0]),
2952
+ price_incl_tax: shipping_options[0].price_incl_tax,
2953
+ amount: shipping_options[0].amount,
2954
+ data: shipping_options[0].data,
2955
+ }
2956
+ : null,
2957
+ });
2958
+
2959
+ const methods = shipping_options.map((option: any) => ({
2960
+ id: option.id,
2961
+ name: option.name,
2962
+ price: {
2963
+ amount: option.price_incl_tax?.toString() || option.amount?.toString() || '0',
2964
+ currencyCode: cart.currency_code?.toUpperCase() || this.currencyCode,
2965
+ },
2966
+ description: option.data?.description,
2967
+ estimatedDays: option.data?.estimated_days || 'N/A',
2968
+ }));
2969
+
2970
+ // Log transformed methods
2971
+ this.logger.info('🚚 [DEBUG] Step 5: Transformed methods for Thor types', {
2972
+ cartId,
2973
+ count: methods.length,
2974
+ methods: JSON.stringify(methods, null, 2),
2975
+ });
2976
+
2977
+ return methods;
2978
+ },
2979
+ 'getShippingMethods',
2980
+ { cartId },
2981
+ );
2982
+ } catch (error: any) {
2983
+ this.logger.error('🚚 [DEBUG] ERROR: Failed to fetch shipping methods', {
2984
+ cartId,
2985
+ errorMessage: error.message,
2986
+ errorStack: error.stack,
2987
+ errorStatus: error.status,
2988
+ });
2989
+ throw new ProviderAPIError(
2990
+ `Failed to fetch shipping methods: ${error.message}`,
2991
+ 'medusa',
2992
+ error.status,
2993
+ );
2994
+ }
2995
+ }
2996
+
2997
+ // ============================================
2998
+ // Admin Authentication (SDK-Managed)
2999
+ // ============================================
3000
+
3001
+ /**
3002
+ * Check if admin API is configured
3003
+ */
3004
+ private get isAdminConfigured(): boolean {
3005
+ return !!this.adminApiKey;
3006
+ }
3007
+
3008
+ /**
3009
+ * Ensure admin is configured.
3010
+ * Synchronous check — API key auth has no async initialization.
3011
+ * @throws ProviderAPIError if admin configuration is missing
3012
+ */
3013
+ private ensureAdminConfigured(): void {
3014
+ if (!this.adminApiKey || !this.sdkAdmin) {
3015
+ throw new ProviderAPIError(
3016
+ 'Admin API is not configured. Pass adminConfig.apiKey to createProvider().',
3017
+ 'medusa',
3018
+ 503,
3019
+ );
3020
+ }
3021
+ }
3022
+
3023
+ /**
3024
+ * Make an authenticated request to the Medusa Admin API via SDK
3025
+ * SDK automatically includes Bearer token in all admin requests
3026
+ * Awaits auth initialization before executing the operation
3027
+ */
3028
+ private async makeAdminRequest<T>(
3029
+ operation: () => Promise<T>,
3030
+ ): Promise<T> {
3031
+ // Ensure admin is configured (sync check — no async auth)
3032
+ this.ensureAdminConfigured();
3033
+
3034
+ try {
3035
+ // Wrap with retry logic to handle transient network failures.
3036
+ // withRetry uses exponential backoff (300ms → 5s, up to 3 attempts).
3037
+ // It retries on: fetch failed, timeout, ECONNREFUSED, ETIMEDOUT, HTTP 429/500/502/503/504.
3038
+ // It does NOT retry 401/403 (auth errors) or 404/422 (business errors).
3039
+ return await this.withRetry(operation, 'adminRequest');
3040
+ } catch (error) {
3041
+ // SDK auth errors will be caught here
3042
+ // If token expired, SDK automatically refreshes
3043
+ // If credentials invalid (401), error propagates
3044
+ if (error instanceof Error) {
3045
+ this.logger.error('Admin API request failed', {
3046
+ error: error.message,
3047
+ });
3048
+ }
3049
+ throw error;
3050
+ }
3051
+ }
3052
+
3053
+ // ============================================
3054
+ // Inventory Operations (Admin API)
3055
+ // ============================================
3056
+
3057
+ /**
3058
+ * Fetch stock locations, optionally filtered by sales channel.
3059
+ * Returns empty array when admin config is not provided (graceful degradation).
3060
+ */
3061
+ async getStockLocations(params?: { salesChannelId?: string }): Promise<StockLocation[]> {
3062
+ if (!this.isAdminConfigured) {
3063
+ return []; // Graceful degradation when admin not configured
3064
+ }
3065
+
3066
+ try {
3067
+ return await this.makeAdminRequest(async () => {
3068
+ const { stock_locations } = await this.sdkAdmin!.admin.stockLocation.list({
3069
+ ...(params?.salesChannelId && { sales_channel_id: [params.salesChannelId] }),
3070
+ });
3071
+
3072
+ return (stock_locations ?? []).map(transformStockLocation);
3073
+ });
3074
+ } catch (error) {
3075
+ this.logger.error('Failed to fetch stock locations:', error);
3076
+ return [];
3077
+ }
3078
+ }
3079
+
3080
+ /**
3081
+ * Check inventory levels for a variant across locations.
3082
+ * Returns empty array when admin config is not provided (graceful degradation).
3083
+ */
3084
+ async checkInventoryLevels(variantId: string, salesChannelId?: string): Promise<InventoryLevel[]> {
3085
+ if (!this.isAdminConfigured) {
3086
+ return [];
3087
+ }
3088
+
3089
+ try {
3090
+ return await this.makeAdminRequest(async () => {
3091
+ // Find inventory item by variant ID
3092
+ const { inventory_items } = await this.sdkAdmin!.admin.inventoryItem.list({
3093
+ // Filter params may need adjustment based on actual SDK capabilities
3094
+ fields: 'id',
3095
+ });
3096
+
3097
+ if (!inventory_items?.length) {
3098
+ return [];
3099
+ }
3100
+
3101
+ // Find the matching inventory item for this variant
3102
+ // Note: SDK filtering might be different - we may need to filter client-side
3103
+ const inventoryItem = inventory_items.find((item: any) => item.variant_id === variantId);
3104
+ if (!inventoryItem) {
3105
+ return [];
3106
+ }
3107
+
3108
+ // Get levels for this inventory item
3109
+ const { inventory_levels } = await this.sdkAdmin!.admin.inventoryItem.listLevels(
3110
+ inventoryItem.id,
3111
+ { fields: 'id,location_id,stocked_quantity,reserved_quantity' },
3112
+ );
3113
+
3114
+ const locations = await this.getStockLocations({ salesChannelId });
3115
+ const locationMap = new Map(locations.map((l) => [l.id, l.name] as const));
3116
+
3117
+ return (inventory_levels ?? []).map((level: any) =>
3118
+ transformInventoryLevel(level, locationMap.get(level.location_id) || 'Unknown'),
3119
+ );
3120
+ });
3121
+ } catch (error) {
3122
+ this.logger.error('Failed to check inventory levels:', error);
3123
+ return [];
3124
+ }
3125
+ }
3126
+
3127
+ /**
3128
+ * Validate cart stock availability across all items.
3129
+ * Returns all-available when admin config is not provided (graceful degradation).
3130
+ */
3131
+ async validateCartStock(cartId: string): Promise<StockValidation> {
3132
+ if (!this.isAdminConfigured) {
3133
+ return { allAvailable: true, unavailableItems: [] };
3134
+ }
3135
+
3136
+ try {
3137
+ const cart = await this.getCart(cartId);
3138
+ if (!cart) {
3139
+ throw new Error('Cart not found');
3140
+ }
3141
+
3142
+ const unavailableItems: StockValidation['unavailableItems'] = [];
3143
+
3144
+ for (const item of cart.lines) {
3145
+ const variantId = item.merchandise.id;
3146
+ const levels = await this.checkInventoryLevels(variantId);
3147
+ const totalAvailable = levels.reduce((sum, level) => sum + level.availableQuantity, 0);
3148
+
3149
+ if (totalAvailable < item.quantity) {
3150
+ unavailableItems.push({
3151
+ variantId,
3152
+ productName: item.merchandise.product.title,
3153
+ requestedQuantity: item.quantity,
3154
+ availableQuantity: totalAvailable,
3155
+ });
3156
+ }
3157
+ }
3158
+
3159
+ return { allAvailable: unavailableItems.length === 0, unavailableItems };
3160
+ } catch (error) {
3161
+ this.logger.error('Failed to validate cart stock:', error);
3162
+ return { allAvailable: false, unavailableItems: [] };
3163
+ }
3164
+ }
3165
+
3166
+ /**
3167
+ * Get fulfillment options enriched with stock location data.
3168
+ * Not yet implemented — requires fulfillment planning + Admin inventory.
3169
+ */
3170
+ async getFulfillmentOptions(cartId: string, salesChannelId: string): Promise<FulfillmentOption[]> {
3171
+ void cartId;
3172
+ void salesChannelId;
3173
+ throw new Error(
3174
+ 'MedusaProvider.getFulfillmentOptions is not yet implemented. ' +
3175
+ 'This method requires Admin API access — implement it HERE in @thorprovider/adapters, ' +
3176
+ 'NOT in L5 starters. See docs/architecture/data-flow.md.',
3177
+ );
3178
+ }
3179
+
3180
+ /**
3181
+ * Get a map of product IDs → admin status ('published' | 'draft' | etc.)
3182
+ * Returns {} when admin config is not provided (graceful degradation).
3183
+ */
3184
+ async getAdminProductStatuses(options?: { limit?: number }): Promise<Record<string, string>> {
3185
+ if (!this.isAdminConfigured) {
3186
+ return {};
3187
+ }
3188
+
3189
+ try {
3190
+ const limit = options?.limit ?? 1000;
3191
+
3192
+ return await this.makeAdminRequest(async () => {
3193
+ const { products } = await this.sdkAdmin!.admin.product.list({
3194
+ limit,
3195
+ fields: 'id,status',
3196
+ });
3197
+
3198
+ const map: Record<string, string> = {};
3199
+ for (const p of products ?? []) {
3200
+ if (p.id && p.status) {
3201
+ map[p.id] = p.status;
3202
+ }
3203
+ }
3204
+ return map;
3205
+ });
3206
+ } catch (error) {
3207
+ this.logger.error('Failed to fetch admin product statuses:', error);
3208
+ return {};
3209
+ }
3210
+ }
3211
+
3212
+ /**
3213
+ * Get the distinct product status values available in Medusa.
3214
+ * Derives values from getAdminProductStatuses and caches them for the process lifetime.
3215
+ * Returns [] when admin credentials are not configured.
3216
+ */
3217
+ async getAvailableProductStatuses(): Promise<string[]> {
3218
+ if (this._cachedProductStatuses !== null) {
3219
+ return this._cachedProductStatuses;
3220
+ }
3221
+
3222
+ if (!this.isAdminConfigured) {
3223
+ return [];
3224
+ }
3225
+
3226
+ try {
3227
+ return await this.makeAdminRequest(async () => {
3228
+ const { products } = await this.sdkAdmin!.admin.product.list({
3229
+ limit: 1000,
3230
+ fields: 'id,status',
3231
+ });
3232
+
3233
+ const statuses = Array.from(
3234
+ new Set((products ?? []).map((p: any) => p.status).filter(Boolean)),
3235
+ );
3236
+ this._cachedProductStatuses = statuses;
3237
+ return statuses;
3238
+ });
3239
+ } catch (error) {
3240
+ this.logger.error('Failed to fetch available product statuses:', error);
3241
+ return [];
3242
+ }
3243
+ }
3244
+
3245
+ /**
3246
+ * List ALL products from the admin API (including non-published).
3247
+ * Returns real backend status values and variant-level price/inventory data.
3248
+ * Gracefully degrades to empty result when admin credentials are not configured.
3249
+ */
3250
+ async getAdminProducts(options?: {
3251
+ query?: string;
3252
+ limit?: number;
3253
+ offset?: number;
3254
+ }): Promise<{ products: AdminProduct[]; count: number }> {
3255
+ if (!this.isAdminConfigured) {
3256
+ return { products: [], count: 0 };
3257
+ }
3258
+
3259
+ try {
3260
+ const limit = options?.limit ?? 1000;
3261
+ const offset = options?.offset ?? 0;
3262
+
3263
+ return await this.makeAdminRequest(async () => {
3264
+ const { products, count } = await this.sdkAdmin!.admin.product.list({
3265
+ limit,
3266
+ offset,
3267
+ q: options?.query,
3268
+ fields:
3269
+ 'id,handle,title,status,thumbnail,' +
3270
+ '+variants,+variants.prices,+variants.inventory_quantity,+variants.sku,' +
3271
+ '+variants.options,+variants.options.option',
3272
+ });
3273
+
3274
+ const transformedProducts: AdminProduct[] = (products ?? []).map((p: any) => ({
3275
+ id: p.id,
3276
+ handle: p.handle,
3277
+ title: p.title,
3278
+ status: p.status,
3279
+ thumbnail: p.thumbnail ?? undefined,
3280
+ variants: (p.variants ?? []).map((v: any) => {
3281
+ // Build a readable title: explicit title > options joined > SKU > "Default"
3282
+ let variantTitle = v.title;
3283
+ if (!variantTitle && Array.isArray(v.options) && v.options.length > 0) {
3284
+ variantTitle = v.options
3285
+ .map((opt: any) => opt.value)
3286
+ .filter(Boolean)
3287
+ .join(' / ');
3288
+ }
3289
+ if (!variantTitle) variantTitle = v.sku || 'Default';
3290
+
3291
+ return {
3292
+ id: v.id,
3293
+ title: variantTitle,
3294
+ sku: v.sku || undefined,
3295
+ quantityAvailable: v.inventory_quantity ?? undefined,
3296
+ prices: (v.prices ?? []).map((pr: any) => ({
3297
+ amount: pr.amount,
3298
+ currencyCode: (pr.currency_code ?? '').toUpperCase(),
3299
+ })),
3300
+ };
3301
+ }),
3302
+ }));
3303
+
3304
+ return { products: transformedProducts, count: count ?? transformedProducts.length };
3305
+ });
3306
+ } catch (error) {
3307
+ // Re-throw authentication errors (401) — these are not transient failures
3308
+ if (error instanceof ProviderAPIError && error.status === 401) {
3309
+ throw error;
3310
+ }
3311
+
3312
+ // Silently degrade for other errors (network, service unavailable, etc.)
3313
+ this.logger.error('Failed to fetch admin products:', error);
3314
+ return { products: [], count: 0 };
3315
+ }
3316
+ }
3317
+
3318
+ /**
3319
+ * Get available countries for a region
3320
+ *
3321
+ * Returns list of countries that can be used for shipping addresses
3322
+ * in the specified region. Results are cached for 5 minutes.
3323
+ *
3324
+ * @param regionId - Region ID
3325
+ * @returns Array of countries with codes and names
3326
+ * @throws {ProviderAPIError} If region not found or API error
3327
+ *
3328
+ * @example
3329
+ * ```typescript
3330
+ * const countries = await medusa.getRegionCountries('reg_01KHCJQ5YXYRWWCKSTYQ4SCM8V');
3331
+ * // [{ code: 'ES', name: 'Spain' }, { code: 'FR', name: 'France' }, ...]
3332
+ * ```
3333
+ */
3334
+ async getRegionCountries(regionId: string): Promise<import('@thorprovider/types').Country[]> {
3335
+ this.logger.info('🌍 [DEBUG] getRegionCountries called', { regionId });
3336
+
3337
+ // Check cache first
3338
+ const cached = this.regionCountriesCache.get(regionId);
3339
+ const now = Date.now();
3340
+
3341
+ if (cached && cached.expiresAt > now) {
3342
+ this.logger.info('🌍 [DEBUG] Returning cached countries', {
3343
+ regionId,
3344
+ count: cached.countries.length,
3345
+ cacheAge:
3346
+ Math.round((now - (cached.expiresAt - this.REGION_COUNTRIES_CACHE_TTL)) / 1000) + 's',
3347
+ });
3348
+ return cached.countries;
3349
+ }
3350
+
3351
+ try {
3352
+ return await this.withRetry(
3353
+ async () => {
3354
+ this.logger.info('🌍 [DEBUG] Fetching countries from Medusa API...', { regionId });
3355
+
3356
+ // Fetch region with countries using Medusa SDK
3357
+ const { region } = await this.sdk.store.region.retrieve(regionId, {
3358
+ fields:
3359
+ '+countries.iso_2,+countries.display_name,+countries.name,+countries.iso_3,+countries.num_code',
3360
+ });
3361
+
3362
+ if (!region) {
3363
+ throw new ResourceNotFoundError('Region', regionId);
3364
+ }
3365
+
3366
+ this.logger.info('🌍 [DEBUG] Region retrieved', {
3367
+ regionId,
3368
+ regionName: region.name,
3369
+ hasCountries: !!(region as any).countries,
3370
+ countriesCount: (region as any).countries?.length || 0,
3371
+ });
3372
+
3373
+ // Transform to Country type
3374
+ const countries: import('@thorprovider/types').Country[] = ((region as any).countries || []).map(
3375
+ (c: any) => ({
3376
+ code: c.iso_2.toUpperCase(), // Frontend expects uppercase for validation
3377
+ name: c.display_name || c.name,
3378
+ iso3: c.iso_3,
3379
+ numCode: c.num_code,
3380
+ }),
3381
+ );
3382
+
3383
+ // Sort by name for better UX
3384
+ countries.sort((a, b) => a.name.localeCompare(b.name));
3385
+
3386
+ this.logger.info('🌍 [DEBUG] Countries transformed and sorted', {
3387
+ regionId,
3388
+ count: countries.length,
3389
+ firstThree: countries.slice(0, 3).map((c) => `${c.code}: ${c.name}`),
3390
+ });
3391
+
3392
+ // Cache the result
3393
+ this.regionCountriesCache.set(regionId, {
3394
+ countries,
3395
+ expiresAt: now + this.REGION_COUNTRIES_CACHE_TTL,
3396
+ });
3397
+
3398
+ return countries;
3399
+ },
3400
+ 'getRegionCountries',
3401
+ { regionId },
3402
+ );
3403
+ } catch (error: any) {
3404
+ this.logger.error('🌍 [DEBUG] ERROR: Failed to fetch region countries', {
3405
+ regionId,
3406
+ errorMessage: error.message,
3407
+ errorStatus: error.status,
3408
+ });
3409
+
3410
+ // Provide helpful error messages
3411
+ if (error.status === 404) {
3412
+ throw new ResourceNotFoundError('Region', regionId);
3413
+ }
3414
+
3415
+ throw new ProviderAPIError(
3416
+ `Failed to fetch countries for region: ${error.message}`,
3417
+ 'medusa',
3418
+ error.status,
3419
+ );
3420
+ }
3421
+ }
3422
+
3423
+ /**
3424
+ * Add shipping method to cart
3425
+ *
3426
+ * Applies a shipping method to the cart and updates totals.
3427
+ *
3428
+ * @param cartId - Cart ID
3429
+ * @param methodId - Shipping method ID (from getShippingMethods)
3430
+ * @returns Updated cart with shipping
3431
+ * @throws {ProviderAPIError} If method invalid or cart not ready
3432
+ */
3433
+ async addShippingMethod(cartId: string, methodId: string): Promise<Cart> {
3434
+ this.logger.debug('addShippingMethod', { cartId, methodId });
3435
+
3436
+ try {
3437
+ return await this.withRetry(
3438
+ async () => {
3439
+ // Medusa v2: POST /store/carts/:id/shipping-methods
3440
+ const { cart } = await this.sdk.store.cart.addShippingMethod(cartId, {
3441
+ option_id: methodId,
3442
+ } as any);
3443
+
3444
+ this.logger.info('Shipping method added successfully', { cartId, methodId });
3445
+
3446
+ void cart;
3447
+ const updatedCart = await this.retrieveCartWithLineDetails(cartId);
3448
+ return transformCart(updatedCart);
3449
+ },
3450
+ 'addShippingMethod',
3451
+ { cartId, methodId },
3452
+ );
3453
+ } catch (error: any) {
3454
+ this.logger.error('Failed to add shipping method', {
3455
+ cartId,
3456
+ methodId,
3457
+ error: error.message,
3458
+ });
3459
+ throw new ProviderAPIError(
3460
+ `Failed to add shipping method: ${error.message}`,
3461
+ 'medusa',
3462
+ error.status,
3463
+ );
3464
+ }
3465
+ }
3466
+
3467
+ /**
3468
+ * Initiate payment session with provider
3469
+ *
3470
+ * Creates a payment session for the cart with the specified provider.
3471
+ *
3472
+ * @param cartId - Cart ID
3473
+ * @param providerId - Payment provider ID (from getPaymentMethods)
3474
+ * @throws {ProviderAPIError} If provider invalid or cart not ready
3475
+ */
3476
+ async initiatePaymentSession(cartId: string, providerId: string): Promise<void> {
3477
+ this.logger.info('💳 [DEBUG] initiatePaymentSession called', { cartId, providerId });
3478
+
3479
+ try {
3480
+ await this.withRetry(
3481
+ async () => {
3482
+ // Get RAW cart object from Medusa SDK (not transformed)
3483
+ // CHANGE 1: Add payment_collection fields to query
3484
+ this.logger.info(
3485
+ '💳 [DEBUG] Step 1: Retrieving RAW cart from Medusa for payment session...',
3486
+ { cartId },
3487
+ );
3488
+ const { cart: rawCart } = await this.sdk.store.cart.retrieve(cartId, {
3489
+ fields:
3490
+ '+shipping_methods,+shipping_address,+email,+region_id,+total,+items,+payment_collection,+payment_collection.payment_sessions,+billing_address',
3491
+ });
3492
+
3493
+ this.logger.info('💳 [DEBUG] Step 2: RAW cart retrieved, checking state...', {
3494
+ cartId: rawCart.id,
3495
+ hasShippingMethod: !!rawCart.shipping_methods?.length,
3496
+ shippingMethodIds: rawCart.shipping_methods?.map((sm: any) => sm.id) || [],
3497
+ hasShippingAddress: !!rawCart.shipping_address,
3498
+ shippingAddressCity: rawCart.shipping_address?.city || 'N/A',
3499
+ hasEmail: !!rawCart.email,
3500
+ email: rawCart.email || 'N/A',
3501
+ hasBillingAddress: !!rawCart.billing_address,
3502
+ hasPaymentCollection: !!rawCart.payment_collection,
3503
+ existingSessionsCount: rawCart.payment_collection?.payment_sessions?.length || 0,
3504
+ regionId: rawCart.region_id,
3505
+ total: rawCart.total,
3506
+ itemCount: rawCart.items?.length || 0,
3507
+ });
3508
+
3509
+ // 🛡️ VALIDATION: Cart must have items to create payment session
3510
+ if (!rawCart.items || rawCart.items.length === 0) {
3511
+ this.logger.error('💳 [DEBUG] ERROR: Cannot create payment session for empty cart', {
3512
+ cartId,
3513
+ itemCount: 0,
3514
+ reason: 'Cart has no items',
3515
+ });
3516
+ throw new ProviderAPIError(
3517
+ 'Cannot create payment session: cart is empty. Please add items to your cart before checking out.',
3518
+ 'medusa',
3519
+ 400,
3520
+ );
3521
+ }
3522
+
3523
+ // 🛡️ VALIDATION: Required fields
3524
+ if (!rawCart.shipping_address) {
3525
+ this.logger.error('💳 [DEBUG] ERROR: Shipping address required', { cartId });
3526
+ throw new ProviderAPIError(
3527
+ 'Shipping address is required before initiating payment session',
3528
+ 'medusa',
3529
+ 400,
3530
+ );
3531
+ }
3532
+
3533
+ if (!rawCart.shipping_methods?.length) {
3534
+ this.logger.error('💳 [DEBUG] ERROR: Shipping method required', { cartId });
3535
+ throw new ProviderAPIError(
3536
+ 'Shipping method is required before initiating payment session',
3537
+ 'medusa',
3538
+ 400,
3539
+ );
3540
+ }
3541
+
3542
+ if (!rawCart.email) {
3543
+ this.logger.error('💳 [DEBUG] ERROR: Email required', { cartId });
3544
+ throw new ProviderAPIError(
3545
+ 'Email is required before initiating payment session',
3546
+ 'medusa',
3547
+ 400,
3548
+ );
3549
+ }
3550
+
3551
+ // CHANGE 2: Validate billing address and warn if missing
3552
+ if (!rawCart.billing_address) {
3553
+ this.logger.warn(
3554
+ '💳 [DEBUG] WARNING: Billing address not set, payment provider may require it',
3555
+ {
3556
+ cartId: rawCart.id,
3557
+ shippingAddress: rawCart.shipping_address,
3558
+ },
3559
+ );
3560
+ }
3561
+
3562
+ // CHANGE 3: Check for existing payment sessions (idempotency)
3563
+ // Note: payment_collection is created automatically by Medusa when calling initiatePaymentSession
3564
+ // So we don't need to validate its existence - just check if sessions already exist
3565
+ const existingSessions = rawCart.payment_collection?.payment_sessions || [];
3566
+
3567
+ if (existingSessions.length > 0) {
3568
+ this.logger.info('💳 [DEBUG] Payment session already exists, skipping creation', {
3569
+ cartId: rawCart.id,
3570
+ sessionCount: existingSessions.length,
3571
+ sessionIds: existingSessions.map((s: any) => s.id),
3572
+ });
3573
+
3574
+ // Return early - session already exists
3575
+ return;
3576
+ }
3577
+
3578
+ this.logger.info('💳 [DEBUG] Step 2.5: No existing payment sessions, will create new one', {
3579
+ cartId: rawCart.id,
3580
+ hasPaymentCollection: !!rawCart.payment_collection,
3581
+ providerId,
3582
+ });
3583
+
3584
+ // Medusa v2 SDK: sdk.store.payment.initiatePaymentSession(cart, options)
3585
+ this.logger.info('💳 [DEBUG] Step 3: Calling Medusa SDK initiatePaymentSession...', {
3586
+ cartId,
3587
+ providerId,
3588
+ endpoint: `${this.baseUrl}store/payment-collections`,
3589
+ note: 'About to call SDK method',
3590
+ });
3591
+
3592
+ // VALIDATION: Ensure provider_id is a valid backend ID
3593
+ // Prevent sending normalized types like 'card', 'wallet', 'cash_on_delivery'
3594
+ this.logger.info('💳 [DEBUG] Step 2.5: Validating provider ID...', {
3595
+ providerId,
3596
+ });
3597
+
3598
+ try {
3599
+ validatePaymentId(providerId);
3600
+ this.logger.info('💳 [DEBUG] Provider ID validation passed', { providerId });
3601
+ } catch (validationError: any) {
3602
+ this.logger.error('💳 [DEBUG] ERROR: Invalid provider ID detected', {
3603
+ providerId,
3604
+ error: validationError.message,
3605
+ hint: 'Use getBackendProviderId(paymentMethod) to extract correct ID',
3606
+ });
3607
+ throw new ProviderAPIError(
3608
+ validationError.message,
3609
+ 'medusa',
3610
+ 400
3611
+ );
3612
+ }
3613
+
3614
+ let paymentResponse: any;
3615
+ try {
3616
+ paymentResponse = await this.sdk.store.payment.initiatePaymentSession(rawCart as any, {
3617
+ provider_id: providerId,
3618
+ });
3619
+
3620
+ this.logger.info('💳 [DEBUG] Step 4: Payment session response received', {
3621
+ cartId,
3622
+ providerId,
3623
+ responseKeys: paymentResponse ? Object.keys(paymentResponse) : [],
3624
+ response: JSON.stringify(paymentResponse, null, 2),
3625
+ });
3626
+ } catch (sdkError: any) {
3627
+ // CHANGE 4: Enhanced error context
3628
+ this.logger.error(
3629
+ '💳 [DEBUG] ERROR: Medusa SDK threw error when initiating payment session',
3630
+ {
3631
+ cartId,
3632
+ providerId,
3633
+ errorType: sdkError?.constructor?.name,
3634
+ errorMessage: sdkError?.message,
3635
+ errorStatus: sdkError?.status,
3636
+ errorCode: sdkError?.code,
3637
+ errorResponse: sdkError?.response
3638
+ ? JSON.stringify(sdkError.response, null, 2)
3639
+ : 'N/A',
3640
+ errorBody: sdkError?.body ? JSON.stringify(sdkError.body, null, 2) : 'N/A',
3641
+ fullError: JSON.stringify(sdkError, Object.getOwnPropertyNames(sdkError), 2),
3642
+ // Include cart state in error for debugging
3643
+ cartState: {
3644
+ hasShippingMethod: !!rawCart.shipping_methods?.length,
3645
+ hasShippingAddress: !!rawCart.shipping_address,
3646
+ hasBillingAddress: !!rawCart.billing_address,
3647
+ hasEmail: !!rawCart.email,
3648
+ total: rawCart.total,
3649
+ itemCount: rawCart.items?.length || 0,
3650
+ },
3651
+ },
3652
+ );
3653
+ throw sdkError;
3654
+ }
3655
+
3656
+ this.logger.info('💳 [DEBUG] Payment session initiated successfully', {
3657
+ cartId,
3658
+ providerId,
3659
+ });
3660
+ },
3661
+ 'initiatePaymentSession',
3662
+ { cartId, providerId },
3663
+ );
3664
+ } catch (error: any) {
3665
+ this.logger.error('💳 [DEBUG] ERROR: Failed to initiate payment session', {
3666
+ cartId,
3667
+ providerId,
3668
+ errorMessage: error.message,
3669
+ errorStack: error.stack,
3670
+ errorStatus: error.status,
3671
+ });
3672
+ throw new ProviderAPIError(
3673
+ `Failed to initiate payment session: ${error.message}`,
3674
+ 'medusa',
3675
+ error.status,
3676
+ );
3677
+ }
3678
+ }
3679
+
3680
+ /**
3681
+ * Apply discount code to cart
3682
+ *
3683
+ * Validates and applies a discount/promo code to the cart.
3684
+ *
3685
+ * @param cartId - Cart ID
3686
+ * @param code - Discount code
3687
+ * @returns Updated cart with discount
3688
+ * @throws {ProviderAPIError} If code invalid or expired
3689
+ */
3690
+ async applyDiscountCode(cartId: string, code: string): Promise<Cart> {
3691
+ this.logger.debug('applyDiscountCode', { cartId, code });
3692
+
3693
+ try {
3694
+ return await this.withRetry(
3695
+ async () => {
3696
+ // Medusa v2: POST /store/carts/:id/promotions
3697
+ const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
3698
+ method: 'POST',
3699
+ headers: {
3700
+ 'Content-Type': 'application/json',
3701
+ 'x-publishable-api-key': this.publishableKey || '',
3702
+ },
3703
+ body: JSON.stringify({
3704
+ promo_codes: [code],
3705
+ }),
3706
+ });
3707
+
3708
+ if (!response.ok) {
3709
+ const error = await response.json();
3710
+ throw new Error(error.message || 'Failed to apply discount code');
3711
+ }
3712
+
3713
+ const { cart: medusaCart } = await response.json();
3714
+
3715
+ this.logger.info('Discount code applied successfully', { cartId, code });
3716
+
3717
+ void medusaCart;
3718
+ const updatedCart = await this.retrieveCartWithLineDetails(cartId);
3719
+ return transformCart(updatedCart);
3720
+ },
3721
+ 'applyDiscountCode',
3722
+ { cartId, code },
3723
+ );
3724
+ } catch (error: any) {
3725
+ this.logger.error('Failed to apply discount code', { cartId, code, error: error.message });
3726
+ throw new ProviderAPIError(
3727
+ `Failed to apply discount code: ${error.message}`,
3728
+ 'medusa',
3729
+ error.status,
3730
+ );
3731
+ }
3732
+ }
3733
+
3734
+ /**
3735
+ * Remove discount code from cart
3736
+ *
3737
+ * Removes a previously applied discount code.
3738
+ *
3739
+ * @param cartId - Cart ID
3740
+ * @param code - Discount code to remove
3741
+ * @returns Updated cart without discount
3742
+ * @throws {ProviderAPIError} If cart not found
3743
+ */
3744
+ async removeDiscountCode(cartId: string, code: string): Promise<Cart> {
3745
+ this.logger.debug('removeDiscountCode', { cartId, code });
3746
+
3747
+ try {
3748
+ return await this.withRetry(
3749
+ async () => {
3750
+ // Medusa v2: DELETE /store/carts/:id/promotions
3751
+ const response = await fetch(`${this.baseUrl}/store/carts/${cartId}/promotions`, {
3752
+ method: 'DELETE',
3753
+ headers: {
3754
+ 'Content-Type': 'application/json',
3755
+ 'x-publishable-api-key': this.publishableKey || '',
3756
+ },
3757
+ body: JSON.stringify({
3758
+ promo_codes: [code],
3759
+ }),
3760
+ });
3761
+
3762
+ if (!response.ok) {
3763
+ const error = await response.json();
3764
+ throw new Error(error.message || 'Failed to remove discount code');
3765
+ }
3766
+
3767
+ const { cart: medusaCart } = await response.json();
3768
+
3769
+ this.logger.info('Discount code removed successfully', { cartId, code });
3770
+
3771
+ void medusaCart;
3772
+ const updatedCart = await this.retrieveCartWithLineDetails(cartId);
3773
+ return transformCart(updatedCart);
3774
+ },
3775
+ 'removeDiscountCode',
3776
+ { cartId, code },
3777
+ );
3778
+ } catch (error: any) {
3779
+ this.logger.error('Failed to remove discount code', { cartId, code, error: error.message });
3780
+ throw new ProviderAPIError(
3781
+ `Failed to remove discount code: ${error.message}`,
3782
+ 'medusa',
3783
+ error.status,
3784
+ );
3785
+ }
3786
+ }
3787
+
3788
+ /**
3789
+ * Update cart shipping address
3790
+ * @param cartId - Cart ID
3791
+ * @param address - Shipping address details
3792
+ * @returns Updated cart
3793
+ *
3794
+ * **CRITICAL**: Must be called BEFORE initiatePaymentSession
3795
+ * Medusa requires shipping_address on cart before creating payment session
3796
+ */
3797
+ async updateShippingAddress(
3798
+ cartId: string,
3799
+ address: {
3800
+ email: string;
3801
+ firstName: string;
3802
+ lastName: string;
3803
+ address1: string;
3804
+ address2?: string;
3805
+ company?: string;
3806
+ city: string;
3807
+ province: string;
3808
+ postalCode: string;
3809
+ countryCode: string;
3810
+ phone: string;
3811
+ },
3812
+ ): Promise<void> {
3813
+ this.logger.info('📫 [DEBUG] updateShippingAddress called', {
3814
+ cartId,
3815
+ email: address.email,
3816
+ city: address.city,
3817
+ countryCode: address.countryCode,
3818
+ });
3819
+
3820
+ try {
3821
+ // Update cart with shipping address using Medusa SDK
3822
+ const { cart: rawCart } = await this.sdk.store.cart.update(cartId, {
3823
+ email: address.email,
3824
+ shipping_address: {
3825
+ first_name: address.firstName,
3826
+ last_name: address.lastName,
3827
+ address_1: address.address1,
3828
+ address_2: address.address2 || '',
3829
+ company: address.company || '',
3830
+ city: address.city,
3831
+ province: address.province,
3832
+ postal_code: address.postalCode,
3833
+ country_code: address.countryCode.toLowerCase(), // Medusa expects lowercase
3834
+ phone: address.phone,
3835
+ },
3836
+ });
3837
+
3838
+ this.logger.info('📫 [DEBUG] Shipping address updated successfully', {
3839
+ cartId,
3840
+ hasEmail: !!rawCart.email,
3841
+ hasShippingAddress: !!rawCart.shipping_address,
3842
+ });
3843
+ } catch (error: unknown) {
3844
+ const errorMessage = extractErrorMessage(error);
3845
+ const status = (error as any)?.status || (error as any)?.statusCode;
3846
+
3847
+ this.logger.error('Failed to update shipping address', {
3848
+ cartId,
3849
+ error: errorMessage,
3850
+ status,
3851
+ });
3852
+
3853
+ // Provide user-friendly error messages
3854
+ let userMessage = errorMessage;
3855
+
3856
+ if (errorMessage.includes('is not within region')) {
3857
+ // Region/Country mismatch
3858
+ userMessage = `The selected country is not available for shipping in your current region. Please select a different country or change your region.`;
3859
+ } else if (status === 400) {
3860
+ userMessage = `Invalid shipping address: ${errorMessage}`;
3861
+ }
3862
+
3863
+ throw new ProviderAPIError(userMessage, 'medusa', status);
3864
+ }
3865
+ }
3866
+
3867
+ /**
3868
+ * Update cart billing address
3869
+ * @param cartId - Cart ID
3870
+ * @param address - Billing address details
3871
+ * @returns Updated cart
3872
+ *
3873
+ * **Usage**: Call when billing address differs from shipping address
3874
+ * Recommended to call BEFORE initiatePaymentSession
3875
+ */
3876
+ async updateBillingAddress(
3877
+ cartId: string,
3878
+ address: {
3879
+ firstName: string;
3880
+ lastName: string;
3881
+ address1: string;
3882
+ address2?: string;
3883
+ company?: string;
3884
+ city: string;
3885
+ province: string;
3886
+ postalCode: string;
3887
+ countryCode: string;
3888
+ phone?: string;
3889
+ },
3890
+ ): Promise<Cart> {
3891
+ this.logger.info('📫 [DEBUG] updateBillingAddress called', {
3892
+ cartId,
3893
+ city: address.city,
3894
+ countryCode: address.countryCode,
3895
+ });
3896
+
3897
+ try {
3898
+ // Update cart with billing address using Medusa SDK
3899
+ const { cart: rawCart } = await this.sdk.store.cart.update(cartId, {
3900
+ billing_address: {
3901
+ first_name: address.firstName,
3902
+ last_name: address.lastName,
3903
+ address_1: address.address1,
3904
+ address_2: address.address2 || '',
3905
+ company: address.company || '',
3906
+ city: address.city,
3907
+ province: address.province,
3908
+ postal_code: address.postalCode,
3909
+ country_code: address.countryCode.toLowerCase(), // Medusa expects lowercase
3910
+ phone: address.phone || '',
3911
+ },
3912
+ });
3913
+
3914
+ this.logger.info('📫 [DEBUG] Billing address updated successfully', {
3915
+ cartId,
3916
+ hasBillingAddress: !!rawCart.billing_address,
3917
+ });
3918
+
3919
+ return transformCart(rawCart);
3920
+ } catch (error: unknown) {
3921
+ const errorMessage = extractErrorMessage(error);
3922
+ const status = (error as any)?.status || (error as any)?.statusCode;
3923
+
3924
+ this.logger.error('Failed to update billing address', {
3925
+ cartId,
3926
+ error: errorMessage,
3927
+ status,
3928
+ });
3929
+
3930
+ // Provide user-friendly error messages
3931
+ let userMessage = errorMessage;
3932
+
3933
+ if (errorMessage.includes('is not within region')) {
3934
+ userMessage = `The billing address country is not available in your current region. Please select a different country or change your region.`;
3935
+ } else if (status === 400) {
3936
+ userMessage = `Invalid billing address: ${errorMessage}`;
3937
+ }
3938
+
3939
+ throw new ProviderAPIError(userMessage, 'medusa', status);
3940
+ }
3941
+ }
3942
+
3943
+ // ============================================
3944
+ // Storefront / Multi-Tenant Operations
3945
+ // ============================================
3946
+
3947
+ /**
3948
+ * Get the current storefront/sales channel context.
3949
+ *
3950
+ * Validates that NEXT_PUBLIC_SALES_CHANNEL_ID is set and that the
3951
+ * corresponding sales channel exists in the Medusa backend.
3952
+ * Results are cached for 60 seconds.
3953
+ *
3954
+ * @returns StorefrontContext for the current storefront
3955
+ * @throws {StorefrontConfigError} if NEXT_PUBLIC_SALES_CHANNEL_ID is missing or invalid
3956
+ * @throws {ProviderAPIError} if the backend request fails
3957
+ */
3958
+ async getStorefrontContext(): Promise<StorefrontContext> {
3959
+ // Check cache first
3960
+ if (
3961
+ this.storefrontContextCache &&
3962
+ Date.now() < this.storefrontContextCache.expiresAt
3963
+ ) {
3964
+ return this.storefrontContextCache.context;
3965
+ }
3966
+
3967
+ const channelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
3968
+ if (!channelId) {
3969
+ throw new StorefrontConfigError(
3970
+ 'Medusa requires NEXT_PUBLIC_SALES_CHANNEL_ID to be configured. ' +
3971
+ 'Each storefront must be linked to a sales channel. ' +
3972
+ 'Set this in your .env file or environment variables.',
3973
+ 'medusa',
3974
+ 'NEXT_PUBLIC_SALES_CHANNEL_ID',
3975
+ );
3976
+ }
3977
+
3978
+ try {
3979
+ // In Medusa v2, we build context from the channel ID.
3980
+ // The channel is validated implicitly when used in API calls (product filtering, cart creation).
3981
+ // If the channel is invalid, those operations will fail with clear errors.
3982
+
3983
+ const context: StorefrontContext = {
3984
+ id: channelId,
3985
+ name: channelId, // Use ID as fallback name
3986
+ platformType: 'medusa',
3987
+ requiresProductScoping: true,
3988
+ currencyCode: this.currencyCode,
3989
+ metadata: {
3990
+ // Store channel ID for use in API requests
3991
+ channelId,
3992
+ },
3993
+ };
3994
+
3995
+ // Cache for 60 seconds
3996
+ this.storefrontContextCache = {
3997
+ context,
3998
+ expiresAt: Date.now() + this.STOREFRONT_CONTEXT_CACHE_TTL,
3999
+ };
4000
+
4001
+ this.logger.info('Storefront context initialized', {
4002
+ id: context.id,
4003
+ channelId,
4004
+ });
4005
+
4006
+ return context;
4007
+ } catch (error) {
4008
+ if (error instanceof StorefrontConfigError) throw error;
4009
+
4010
+ const errorMessage = extractErrorMessage(error);
4011
+ throw new ProviderAPIError(
4012
+ `Failed to initialize storefront context: ${errorMessage}`,
4013
+ 'medusa',
4014
+ (error as any)?.status ?? 500,
4015
+ );
4016
+ }
4017
+ }
4018
+
4019
+ /**
4020
+ * Medusa always requires storefront configuration for multi-tenant support.
4021
+ */
4022
+ isStorefrontRequired(): boolean {
4023
+ return true;
4024
+ }
4025
+
4026
+ // ============================================
4027
+ // Thor Plugin — Storefront Configuration
4028
+ // ============================================
4029
+
4030
+ /** In-memory cache for storefront config (60s TTL) */
4031
+ private _storefrontConfigCache: {
4032
+ config: StorefrontConfig | null;
4033
+ expiresAt: number;
4034
+ } | null = null;
4035
+
4036
+ /** In-memory cache for designer config (60s TTL) */
4037
+ private _designerConfigCache: {
4038
+ result: { version: string; config: DesignerConfig } | null;
4039
+ expiresAt: number;
4040
+ } | null = null;
4041
+
4042
+ /** Cache TTL for Thor plugin configs (60 seconds) */
4043
+ private readonly THOR_CONFIG_CACHE_TTL = 60 * 1000;
4044
+
4045
+ /**
4046
+ * Get per-channel storefront configuration from Thor Commerce plugin.
4047
+ * Calls `GET /store/thor/config`.
4048
+ *
4049
+ * @returns StorefrontConfig or null if plugin is not installed / config not set
4050
+ */
4051
+ async getStorefrontConfig(): Promise<StorefrontConfig | null> {
4052
+ // Check cache
4053
+ if (this._storefrontConfigCache && Date.now() < this._storefrontConfigCache.expiresAt) {
4054
+ return this._storefrontConfigCache.config;
4055
+ }
4056
+
4057
+ try {
4058
+ const url = `${this.baseUrl}/store/thor/config`;
4059
+ const response = await fetchWithRetry(
4060
+ url,
4061
+ {
4062
+ method: 'GET',
4063
+ headers: {
4064
+ 'x-publishable-api-key': this.publishableKey,
4065
+ 'Content-Type': 'application/json',
4066
+ },
4067
+ },
4068
+ this.retryConfig,
4069
+ );
4070
+
4071
+ if (response.status === 404) {
4072
+ this._storefrontConfigCache = { config: null, expiresAt: Date.now() + this.THOR_CONFIG_CACHE_TTL };
4073
+ return null;
4074
+ }
4075
+
4076
+ if (!response.ok) {
4077
+ this.logger.warn('[StorefrontConfig] Non-OK response', { status: response.status });
4078
+ return null;
4079
+ }
4080
+
4081
+ const data = await response.json();
4082
+ const config = transformStorefrontConfig(data.storefront_config ?? data);
4083
+
4084
+ this._storefrontConfigCache = { config, expiresAt: Date.now() + this.THOR_CONFIG_CACHE_TTL };
4085
+ this.logger.debug('[StorefrontConfig] Loaded from backend', { id: config.id });
4086
+ return config;
4087
+ } catch (error) {
4088
+ this.logger.warn('[StorefrontConfig] Failed to fetch', {
4089
+ error: error instanceof Error ? error.message : String(error),
4090
+ });
4091
+ return null;
4092
+ }
4093
+ }
4094
+
4095
+ /**
4096
+ * Get active Site Designer configuration from Thor Commerce plugin.
4097
+ * Calls `GET /store/thor/site-config`.
4098
+ *
4099
+ * @returns Versioned DesignerConfig or null if plugin is not installed / config not set
4100
+ */
4101
+ async getDesignerConfig(): Promise<{ version: string; config: DesignerConfig } | null> {
4102
+ // Check cache
4103
+ if (this._designerConfigCache && Date.now() < this._designerConfigCache.expiresAt) {
4104
+ return this._designerConfigCache.result;
4105
+ }
4106
+
4107
+ try {
4108
+ const url = `${this.baseUrl}/store/thor/site-config`;
4109
+ const response = await fetchWithRetry(
4110
+ url,
4111
+ {
4112
+ method: 'GET',
4113
+ headers: {
4114
+ 'x-publishable-api-key': this.publishableKey,
4115
+ 'Content-Type': 'application/json',
4116
+ },
4117
+ },
4118
+ this.retryConfig,
4119
+ );
4120
+
4121
+ if (response.status === 404) {
4122
+ this._designerConfigCache = { result: null, expiresAt: Date.now() + this.THOR_CONFIG_CACHE_TTL };
4123
+ return null;
4124
+ }
4125
+
4126
+ if (!response.ok) {
4127
+ this.logger.warn('[DesignerConfig] Non-OK response', { status: response.status });
4128
+ return null;
4129
+ }
4130
+
4131
+ const data = await response.json();
4132
+ const result = {
4133
+ version: data.version ?? '',
4134
+ config: data.config as DesignerConfig,
4135
+ };
4136
+
4137
+ this._designerConfigCache = { result, expiresAt: Date.now() + this.THOR_CONFIG_CACHE_TTL };
4138
+ this.logger.debug('[DesignerConfig] Loaded from backend', { version: result.version });
4139
+ return result;
4140
+ } catch (error) {
4141
+ this.logger.warn('[DesignerConfig] Failed to fetch', {
4142
+ error: error instanceof Error ? error.message : String(error),
4143
+ });
4144
+ return null;
4145
+ }
4146
+ }
4147
+
4148
+ // ============================================
4149
+ // Thor Plugin — Admin Site Designer
4150
+ // ============================================
4151
+
4152
+ /**
4153
+ * Get admin Site Designer config + history for a sales channel.
4154
+ * Calls `GET /admin/thor/site-config/:salesChannelId`.
4155
+ */
4156
+ async getAdminDesignerConfig(salesChannelId: string): Promise<{
4157
+ version: string;
4158
+ config: DesignerConfig;
4159
+ history?: DesignerHistoryEntry[];
4160
+ } | null> {
4161
+ return this.makeAdminRequest(async () => {
4162
+ const path = `/admin/thor/site-config/${encodeURIComponent(salesChannelId)}`;
4163
+ let data: any;
4164
+ try {
4165
+ data = await this.sdk.client.fetch<any>(path, { method: 'GET' });
4166
+ } catch (error: any) {
4167
+ if (error?.status === 404) return null;
4168
+ throw new ProviderAPIError(
4169
+ `Failed to fetch admin designer config: ${error?.message ?? error?.status}`,
4170
+ 'medusa',
4171
+ error?.status,
4172
+ );
4173
+ }
4174
+ return {
4175
+ version: data.version ?? '',
4176
+ config: data.config as DesignerConfig,
4177
+ history: data.history as DesignerHistoryEntry[] | undefined,
4178
+ };
4179
+ });
4180
+ }
4181
+
4182
+ /**
4183
+ * Update the Site Designer config for a sales channel.
4184
+ * Calls `PUT /admin/thor/site-config/:salesChannelId`.
4185
+ */
4186
+ async updateAdminDesignerConfig(salesChannelId: string, config: DesignerConfig): Promise<{
4187
+ version: string;
4188
+ config: DesignerConfig;
4189
+ }> {
4190
+ return this.makeAdminRequest(async () => {
4191
+ const path = `/admin/thor/site-config/${encodeURIComponent(salesChannelId)}`;
4192
+ let data: any;
4193
+ try {
4194
+ data = await this.sdk.client.fetch<any>(path, {
4195
+ method: 'PUT',
4196
+ body: JSON.stringify({ config }),
4197
+ headers: { 'Content-Type': 'application/json' },
4198
+ });
4199
+ } catch (error: any) {
4200
+ throw new ProviderAPIError(
4201
+ `Failed to update admin designer config: ${error?.message ?? error?.status}`,
4202
+ 'medusa',
4203
+ error?.status,
4204
+ );
4205
+ }
4206
+ return {
4207
+ version: data.version ?? '',
4208
+ config: data.config as DesignerConfig,
4209
+ };
4210
+ });
4211
+ }
4212
+
4213
+ /**
4214
+ * Restore a previous Site Designer config version.
4215
+ * Calls `POST /admin/thor/site-config/:salesChannelId/restore/:version`.
4216
+ */
4217
+ async restoreAdminDesignerConfig(salesChannelId: string, version: string): Promise<{
4218
+ version: string;
4219
+ config: DesignerConfig;
4220
+ }> {
4221
+ return this.makeAdminRequest(async () => {
4222
+ const path = `/admin/thor/site-config/${encodeURIComponent(salesChannelId)}/restore/${encodeURIComponent(version)}`;
4223
+ let data: any;
4224
+ try {
4225
+ data = await this.sdk.client.fetch<any>(path, { method: 'POST' });
4226
+ } catch (error: any) {
4227
+ throw new ProviderAPIError(
4228
+ `Failed to restore admin designer config: ${error?.message ?? error?.status}`,
4229
+ 'medusa',
4230
+ error?.status,
4231
+ );
4232
+ }
4233
+ return {
4234
+ version: data.version ?? '',
4235
+ config: data.config as DesignerConfig,
4236
+ };
4237
+ });
4238
+ }
4239
+ }