@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.
- package/.turbo/turbo-build.log +18 -0
- package/.turbo/turbo-test.log +1 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +271 -0
- package/README.md +634 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.mts +2524 -0
- package/dist/index.d.ts +2524 -0
- package/dist/index.js +8136 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +8045 -0
- package/dist/index.mjs.map +1 -0
- package/docs/RETRY_SOLUTION_SUMMARY.md +55 -0
- package/docs/authentication.md +165 -0
- package/docs/capabilities-system.md +143 -0
- package/docs/category-customization.md +309 -0
- package/docs/fetch-reliability-solution.md +399 -0
- package/docs/getting-started.md +154 -0
- package/docs/multi-tenant-channel-scoping.md +225 -0
- package/docs/profile-management.md +147 -0
- package/docs/providers-overview.md +85 -0
- package/docs/services-overview.md +135 -0
- package/docs/update-documentation.md +113 -0
- package/package.json +43 -0
- package/publish.log +93 -0
- package/quick-reference.md +169 -0
- package/src/admin/AdminAuthService.ts +88 -0
- package/src/admin/AdminSessionService.ts +91 -0
- package/src/admin/MetricsService.ts +145 -0
- package/src/admin/index.ts +13 -0
- package/src/backend/BackendMetadataAdapter.ts +88 -0
- package/src/backend/factory.ts +107 -0
- package/src/backend/index.ts +12 -0
- package/src/backend/medusa/MedusaMetadataAdapter.ts +139 -0
- package/src/core/errors.js +161 -0
- package/src/core/errors.ts +132 -0
- package/src/core/fetch-utils.js +240 -0
- package/src/core/fetch-utils.ts +227 -0
- package/src/core/index.ts +8 -0
- package/src/core/logger.js +63 -0
- package/src/core/logger.ts +37 -0
- package/src/factory/index.ts +102 -0
- package/src/index.ts +205 -0
- package/src/lib/money-transform.ts +80 -0
- package/src/metadata/channel-scope-validation.js +136 -0
- package/src/metadata/channel-scope-validation.ts +151 -0
- package/src/metadata/channel-scope.js +174 -0
- package/src/metadata/channel-scope.ts +215 -0
- package/src/metadata/index.js +31 -0
- package/src/metadata/index.ts +37 -0
- package/src/providers/bigcommerce/bigcommerce.ts +6 -0
- package/src/providers/bigcommerce/index.ts +315 -0
- package/src/providers/bigcommerce/transforms.ts +227 -0
- package/src/providers/index.ts +22 -0
- package/src/providers/magento/index.ts +387 -0
- package/src/providers/magento/magento.ts +5 -0
- package/src/providers/magento/queries.ts +267 -0
- package/src/providers/magento/transforms.ts +249 -0
- package/src/providers/medusa/auth.js +601 -0
- package/src/providers/medusa/auth.ts +496 -0
- package/src/providers/medusa/fulfillment-planner.ts +225 -0
- package/src/providers/medusa/index.js +2485 -0
- package/src/providers/medusa/index.ts +4239 -0
- package/src/providers/medusa/medusa.ts +8 -0
- package/src/providers/medusa/payment.js +265 -0
- package/src/providers/medusa/payment.ts +269 -0
- package/src/providers/medusa/transforms.js +505 -0
- package/src/providers/medusa/transforms.ts +625 -0
- package/src/providers/shopify/index.ts +444 -0
- package/src/providers/shopify/mutations.ts +102 -0
- package/src/providers/shopify/queries.ts +324 -0
- package/src/providers/shopify/shopify.ts +7 -0
- package/src/providers/shopify/transforms.ts +270 -0
- package/src/providers/spree/index.ts +344 -0
- package/src/providers/spree/spree.ts +5 -0
- package/src/providers/spree/transforms.ts +254 -0
- package/src/providers/woocommerce/index.ts +310 -0
- package/src/providers/woocommerce/transforms.ts +254 -0
- package/src/providers/woocommerce/woocommerce.ts +5 -0
- package/src/resilience/README.md +299 -0
- package/src/resilience/circuit-breaker.ts +222 -0
- package/src/resilience/index.ts +10 -0
- package/src/types/capabilities.types.js +141 -0
- package/src/types/capabilities.types.ts +366 -0
- package/src/types/index.ts +24 -0
- package/src/types/provider.js +9 -0
- package/src/types/provider.ts +9 -0
- package/src/types/ui-responses.ts +261 -0
- package/src/utils/city-coordinates.ts +40 -0
- package/src/utils/geolocation.ts +52 -0
- package/src/utils/payment-utils.js +152 -0
- package/src/utils/payment-utils.ts +203 -0
- package/tsconfig.json +15 -0
- 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
|
+
}
|