@thorprovider/adapters 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +18 -0
  2. package/.turbo/turbo-test.log +1 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/CHANGELOG.md +271 -0
  6. package/README.md +634 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/index.d.mts +2524 -0
  9. package/dist/index.d.ts +2524 -0
  10. package/dist/index.js +8136 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/index.mjs +8045 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/docs/RETRY_SOLUTION_SUMMARY.md +55 -0
  15. package/docs/authentication.md +165 -0
  16. package/docs/capabilities-system.md +143 -0
  17. package/docs/category-customization.md +309 -0
  18. package/docs/fetch-reliability-solution.md +399 -0
  19. package/docs/getting-started.md +154 -0
  20. package/docs/multi-tenant-channel-scoping.md +225 -0
  21. package/docs/profile-management.md +147 -0
  22. package/docs/providers-overview.md +85 -0
  23. package/docs/services-overview.md +135 -0
  24. package/docs/update-documentation.md +113 -0
  25. package/package.json +43 -0
  26. package/publish.log +93 -0
  27. package/quick-reference.md +169 -0
  28. package/src/admin/AdminAuthService.ts +88 -0
  29. package/src/admin/AdminSessionService.ts +91 -0
  30. package/src/admin/MetricsService.ts +145 -0
  31. package/src/admin/index.ts +13 -0
  32. package/src/backend/BackendMetadataAdapter.ts +88 -0
  33. package/src/backend/factory.ts +107 -0
  34. package/src/backend/index.ts +12 -0
  35. package/src/backend/medusa/MedusaMetadataAdapter.ts +139 -0
  36. package/src/core/errors.js +161 -0
  37. package/src/core/errors.ts +132 -0
  38. package/src/core/fetch-utils.js +240 -0
  39. package/src/core/fetch-utils.ts +227 -0
  40. package/src/core/index.ts +8 -0
  41. package/src/core/logger.js +63 -0
  42. package/src/core/logger.ts +37 -0
  43. package/src/factory/index.ts +102 -0
  44. package/src/index.ts +205 -0
  45. package/src/lib/money-transform.ts +80 -0
  46. package/src/metadata/channel-scope-validation.js +136 -0
  47. package/src/metadata/channel-scope-validation.ts +151 -0
  48. package/src/metadata/channel-scope.js +174 -0
  49. package/src/metadata/channel-scope.ts +215 -0
  50. package/src/metadata/index.js +31 -0
  51. package/src/metadata/index.ts +37 -0
  52. package/src/providers/bigcommerce/bigcommerce.ts +6 -0
  53. package/src/providers/bigcommerce/index.ts +315 -0
  54. package/src/providers/bigcommerce/transforms.ts +227 -0
  55. package/src/providers/index.ts +22 -0
  56. package/src/providers/magento/index.ts +387 -0
  57. package/src/providers/magento/magento.ts +5 -0
  58. package/src/providers/magento/queries.ts +267 -0
  59. package/src/providers/magento/transforms.ts +249 -0
  60. package/src/providers/medusa/auth.js +601 -0
  61. package/src/providers/medusa/auth.ts +496 -0
  62. package/src/providers/medusa/fulfillment-planner.ts +225 -0
  63. package/src/providers/medusa/index.js +2485 -0
  64. package/src/providers/medusa/index.ts +4239 -0
  65. package/src/providers/medusa/medusa.ts +8 -0
  66. package/src/providers/medusa/payment.js +265 -0
  67. package/src/providers/medusa/payment.ts +269 -0
  68. package/src/providers/medusa/transforms.js +505 -0
  69. package/src/providers/medusa/transforms.ts +625 -0
  70. package/src/providers/shopify/index.ts +444 -0
  71. package/src/providers/shopify/mutations.ts +102 -0
  72. package/src/providers/shopify/queries.ts +324 -0
  73. package/src/providers/shopify/shopify.ts +7 -0
  74. package/src/providers/shopify/transforms.ts +270 -0
  75. package/src/providers/spree/index.ts +344 -0
  76. package/src/providers/spree/spree.ts +5 -0
  77. package/src/providers/spree/transforms.ts +254 -0
  78. package/src/providers/woocommerce/index.ts +310 -0
  79. package/src/providers/woocommerce/transforms.ts +254 -0
  80. package/src/providers/woocommerce/woocommerce.ts +5 -0
  81. package/src/resilience/README.md +299 -0
  82. package/src/resilience/circuit-breaker.ts +222 -0
  83. package/src/resilience/index.ts +10 -0
  84. package/src/types/capabilities.types.js +141 -0
  85. package/src/types/capabilities.types.ts +366 -0
  86. package/src/types/index.ts +24 -0
  87. package/src/types/provider.js +9 -0
  88. package/src/types/provider.ts +9 -0
  89. package/src/types/ui-responses.ts +261 -0
  90. package/src/utils/city-coordinates.ts +40 -0
  91. package/src/utils/geolocation.ts +52 -0
  92. package/src/utils/payment-utils.js +152 -0
  93. package/src/utils/payment-utils.ts +203 -0
  94. package/tsconfig.json +15 -0
  95. package/tsup.config.ts +14 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * @fileoverview Medusa Authentication Provider
3
+ * @module @thorprovider/adapters/providers/medusa/auth
4
+ *
5
+ * Implementation of AuthProvider for Medusa.js v2
6
+ * Supports JWT and session-based authentication
7
+ */
8
+
9
+ import Medusa from '@medusajs/js-sdk';
10
+ import type {
11
+ AuthProvider,
12
+ AuthConfig,
13
+ LoginCredentials,
14
+ RegisterData,
15
+ AuthResponse,
16
+ Customer,
17
+ Address,
18
+ UpdateCustomerData,
19
+ CreateAddressData,
20
+ PasswordResetRequest,
21
+ PasswordResetConfirm,
22
+ } from '@thorprovider/types';
23
+ import { createLogger } from '../../core/logger';
24
+ import { ProviderAPIError } from '../../core/errors';
25
+ import type { Logger } from '../../core/logger';
26
+ import { transformCustomer, transformAddress } from './transforms';
27
+
28
+ /**
29
+ * Storage manager for authentication tokens
30
+ */
31
+ class AuthStorageManager {
32
+ private storage: AuthConfig['customStorage'] | null = null;
33
+ private storageType: AuthConfig['storage'];
34
+ private tokenKey = 'thor_auth_token';
35
+
36
+ constructor(config: AuthConfig) {
37
+ this.storageType = config.storage;
38
+ this.storage = config.customStorage ?? null;
39
+ }
40
+
41
+ async getToken(): Promise<string | null> {
42
+ if (this.storage) {
43
+ return this.storage.getItem(this.tokenKey);
44
+ }
45
+
46
+ if (typeof window === 'undefined') {
47
+ // Server-side: no storage available
48
+ return null;
49
+ }
50
+
51
+ switch (this.storageType) {
52
+ case 'localStorage':
53
+ return window.localStorage.getItem(this.tokenKey);
54
+ case 'sessionStorage':
55
+ return window.sessionStorage.getItem(this.tokenKey);
56
+ case 'cookie':
57
+ // Cookies are handled automatically by fetch, just return null
58
+ return null;
59
+ case 'memory':
60
+ return null;
61
+ default:
62
+ return null;
63
+ }
64
+ }
65
+
66
+ async setToken(token: string): Promise<void> {
67
+ if (this.storage) {
68
+ await this.storage.setItem(this.tokenKey, token);
69
+ return;
70
+ }
71
+
72
+ if (typeof window === 'undefined') {
73
+ return;
74
+ }
75
+
76
+ switch (this.storageType) {
77
+ case 'localStorage':
78
+ window.localStorage.setItem(this.tokenKey, token);
79
+ break;
80
+ case 'sessionStorage':
81
+ window.sessionStorage.setItem(this.tokenKey, token);
82
+ break;
83
+ case 'cookie':
84
+ case 'memory':
85
+ // No action needed for cookie (handled by SDK) or memory
86
+ break;
87
+ }
88
+ }
89
+
90
+ async removeToken(): Promise<void> {
91
+ if (this.storage) {
92
+ await this.storage.removeItem(this.tokenKey);
93
+ return;
94
+ }
95
+
96
+ if (typeof window === 'undefined') {
97
+ return;
98
+ }
99
+
100
+ switch (this.storageType) {
101
+ case 'localStorage':
102
+ window.localStorage.removeItem(this.tokenKey);
103
+ break;
104
+ case 'sessionStorage':
105
+ window.sessionStorage.removeItem(this.tokenKey);
106
+ break;
107
+ case 'cookie':
108
+ case 'memory':
109
+ // No action needed
110
+ break;
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Medusa authentication provider implementation
117
+ */
118
+ export class MedusaAuthProvider implements AuthProvider {
119
+ private sdk: Medusa;
120
+ private logger: ReturnType<typeof createLogger>;
121
+ private authConfig: AuthConfig;
122
+ private storageManager: AuthStorageManager;
123
+
124
+ constructor(sdk: Medusa, authConfig: AuthConfig, debug = false) {
125
+ this.sdk = sdk;
126
+ this.authConfig = authConfig;
127
+ this.logger = createLogger('MedusaAuthProvider', debug);
128
+ this.storageManager = new AuthStorageManager(authConfig);
129
+
130
+ this.logger.info('Medusa Auth Provider initialized', {
131
+ method: authConfig.method,
132
+ storage: authConfig.storage,
133
+ });
134
+ }
135
+
136
+ // ========================================
137
+ // Authentication Methods
138
+ // ========================================
139
+
140
+ async login(credentials: LoginCredentials): Promise<AuthResponse> {
141
+ this.logger.debug('login', { email: credentials.email });
142
+
143
+ try {
144
+ // Step 1: Authenticate with Medusa
145
+ const token = await this.sdk.auth.login('customer', 'emailpass', {
146
+ email: credentials.email,
147
+ password: credentials.password,
148
+ });
149
+
150
+ // Check if token requires additional actions (OAuth flow)
151
+ if (typeof token !== 'string') {
152
+ this.logger.warn('Authentication requires additional actions', token);
153
+ return {
154
+ requiresAction: true,
155
+ redirectUrl: token.location,
156
+ };
157
+ }
158
+
159
+ // Step 2: Store token based on auth method
160
+ if (this.authConfig.method === 'jwt') {
161
+ await this.storageManager.setToken(token);
162
+ }
163
+ // Note: For session-based auth, the SDK automatically handles cookies
164
+ // when auth.login() is called with auth.type='session' in SDK config
165
+
166
+ // Step 3: Fetch customer data
167
+ const customer = await this.getCurrentCustomer();
168
+
169
+ this.logger.info('Login successful', { customerId: customer?.id });
170
+
171
+ return {
172
+ token: this.authConfig.method === 'jwt' ? token : undefined,
173
+ customer: customer ?? undefined,
174
+ };
175
+ } catch (error: any) {
176
+ this.logger.error('Login failed', error);
177
+ throw new ProviderAPIError(
178
+ `Authentication failed: ${error.message}`,
179
+ 'LOGIN_FAILED',
180
+ error
181
+ );
182
+ }
183
+ }
184
+
185
+ async register(data: RegisterData): Promise<AuthResponse> {
186
+ this.logger.debug('register', { email: data.email });
187
+
188
+ try {
189
+ // Step 1: Register auth identity with Medusa
190
+ const token = await this.sdk.auth.register('customer', 'emailpass', {
191
+ email: data.email,
192
+ password: data.password,
193
+ });
194
+
195
+ if (typeof token !== 'string') {
196
+ throw new Error('Registration returned non-string token');
197
+ }
198
+
199
+ // Step 2: Create customer profile
200
+ // Note: The SDK automatically includes the token in the header after auth.register()
201
+ const { customer } = await this.sdk.store.customer.create({
202
+ email: data.email,
203
+ first_name: data.first_name,
204
+ last_name: data.last_name,
205
+ phone: data.phone,
206
+ });
207
+
208
+ // Step 3: Store token based on auth method
209
+ if (this.authConfig.method === 'jwt') {
210
+ await this.storageManager.setToken(token);
211
+ }
212
+ // Note: For session-based auth, the SDK automatically handles cookies
213
+ // when auth.register() is called with auth.type='session' in SDK config
214
+
215
+ this.logger.info('Registration successful', { customerId: customer.id });
216
+
217
+ return {
218
+ token: this.authConfig.method === 'jwt' ? token : undefined,
219
+ customer: transformCustomer(customer, customer.email),
220
+ };
221
+ } catch (error: any) {
222
+ this.logger.error('Registration failed', error);
223
+ throw new ProviderAPIError(
224
+ `Registration failed: ${error.message}`,
225
+ 'REGISTRATION_FAILED',
226
+ error
227
+ );
228
+ }
229
+ }
230
+
231
+ async logout(): Promise<void> {
232
+ this.logger.debug('logout');
233
+
234
+ try {
235
+ await this.storageManager.removeToken();
236
+
237
+ // For session auth, also call logout endpoint
238
+ if (this.authConfig.method === 'session') {
239
+ await this.sdk.auth.logout();
240
+ }
241
+
242
+ this.logger.info('Logout successful');
243
+ } catch (error: any) {
244
+ this.logger.error('Logout failed', error);
245
+ // Don't throw error on logout failure, just log it
246
+ }
247
+ }
248
+
249
+ // ========================================
250
+ // Password Management
251
+ // ========================================
252
+
253
+ async requestPasswordReset(email: string): Promise<void> {
254
+ this.logger.debug('requestPasswordReset', { email });
255
+
256
+ try {
257
+ await this.sdk.auth.resetPassword('customer', 'emailpass', {
258
+ identifier: email,
259
+ });
260
+
261
+ this.logger.info('Password reset email sent', { email });
262
+ } catch (error: any) {
263
+ this.logger.error('Password reset request failed', error);
264
+ throw new ProviderAPIError(
265
+ `Password reset request failed: ${error.message}`,
266
+ 'PASSWORD_RESET_FAILED',
267
+ error
268
+ );
269
+ }
270
+ }
271
+
272
+ async resetPassword(
273
+ token: string,
274
+ password: string,
275
+ metadata?: Record<string, unknown>
276
+ ): Promise<void> {
277
+ this.logger.debug('resetPassword', { hasMetadata: !!metadata });
278
+
279
+ // Medusa requires email in the request body
280
+ // Extract from metadata or decode from token
281
+ const email = metadata?.email as string | undefined;
282
+
283
+ if (!email) {
284
+ throw new ProviderAPIError(
285
+ 'Email is required in metadata for Medusa password reset',
286
+ 'MEDUSA_AUTH_PROVIDER',
287
+ 400
288
+ );
289
+ }
290
+
291
+ try {
292
+ // Medusa v2.6+ API: email + password in body, token as 3rd param
293
+ await this.sdk.auth.updateProvider('customer', 'emailpass', {
294
+ email,
295
+ password,
296
+ }, token);
297
+
298
+ this.logger.info('Password reset successful');
299
+ } catch (error: any) {
300
+ this.logger.error('Password reset failed', error);
301
+ throw new ProviderAPIError(
302
+ `Password reset failed: ${error.message}`,
303
+ 'PASSWORD_RESET_CONFIRM_FAILED',
304
+ error
305
+ );
306
+ }
307
+ }
308
+
309
+ // ========================================
310
+ // Session Management
311
+ // ========================================
312
+
313
+ async getCurrentCustomer(): Promise<Customer | null> {
314
+ this.logger.debug('getCurrentCustomer');
315
+
316
+ try {
317
+ const { customer } = await this.sdk.store.customer.retrieve();
318
+
319
+ if (!customer) {
320
+ return null;
321
+ }
322
+
323
+ return transformCustomer(customer, customer.email);
324
+ } catch (error: any) {
325
+ this.logger.debug('No authenticated customer');
326
+ return null;
327
+ }
328
+ }
329
+
330
+ async updateCustomer(data: UpdateCustomerData): Promise<Customer> {
331
+ this.logger.debug('updateCustomer');
332
+
333
+ try {
334
+ const updateData = { ...data };
335
+ if (updateData.phone === null) {
336
+ delete updateData.phone; // Medusa doesn't accept null, only undefined
337
+ }
338
+ const { customer } = await this.sdk.store.customer.update(updateData as any);
339
+
340
+ return transformCustomer(customer, customer.email);
341
+ } catch (error: any) {
342
+ this.logger.error('Update customer failed', error);
343
+ throw new ProviderAPIError(
344
+ `Failed to update customer: ${error.message}`,
345
+ 'UPDATE_CUSTOMER_FAILED',
346
+ error
347
+ );
348
+ }
349
+ }
350
+
351
+ async isAuthenticated(): Promise<boolean> {
352
+ const customer = await this.getCurrentCustomer();
353
+ return customer !== null;
354
+ }
355
+
356
+ getAuthToken(): string | null {
357
+ // For JWT auth, return stored token
358
+ if (this.authConfig.method === 'jwt') {
359
+ // This is sync, so we can't use async storage
360
+ // Return null and rely on storage manager
361
+ return null;
362
+ }
363
+
364
+ // For session auth, return null (cookies are handled automatically)
365
+ return null;
366
+ }
367
+
368
+ // ========================================
369
+ // Address Management
370
+ // ========================================
371
+
372
+ async getAddresses(): Promise<Address[]> {
373
+ this.logger.debug('getAddresses');
374
+ try {
375
+ const { addresses } = await this.sdk.store.customer.listAddress();
376
+ return addresses.map((address: any) => transformAddress(address));
377
+ } catch (error: any) {
378
+ this.logger.error('Get addresses failed', error);
379
+ throw new ProviderAPIError(
380
+ `Failed to get addresses: ${error.message}`,
381
+ 'GET_ADDRESSES_FAILED',
382
+ error
383
+ );
384
+ }
385
+ }
386
+
387
+ async createAddress(data: CreateAddressData): Promise<Address> {
388
+ this.logger.debug('createAddress');
389
+
390
+ try {
391
+ // Transform camelCase to snake_case for Medusa API
392
+ const medusaData = {
393
+ first_name: data.firstName,
394
+ last_name: data.lastName,
395
+ company: data.company,
396
+ address_1: data.address1,
397
+ address_2: data.address2,
398
+ city: data.city,
399
+ province: data.province,
400
+ postal_code: data.postalCode,
401
+ country_code: data.countryCode,
402
+ phone: data.phone,
403
+ metadata: data.metadata,
404
+ };
405
+
406
+ const { customer } = await this.sdk.store.customer.createAddress(medusaData);
407
+
408
+ // Get the most recent address (last one in array)
409
+ const addresses = customer.addresses || [];
410
+ const address = addresses[addresses.length - 1];
411
+
412
+ if (!address) {
413
+ throw new Error('No address returned from createAddress');
414
+ }
415
+
416
+ return transformAddress(address);
417
+ } catch (error: any) {
418
+ this.logger.error('Create address failed', error);
419
+ throw new ProviderAPIError(
420
+ `Failed to create address: ${error.message}`,
421
+ 'CREATE_ADDRESS_FAILED',
422
+ error
423
+ );
424
+ }
425
+ }
426
+
427
+ async updateAddress(
428
+ addressId: string,
429
+ data: Partial<CreateAddressData>
430
+ ): Promise<Address> {
431
+ this.logger.debug('updateAddress', { addressId });
432
+
433
+ try {
434
+ // Transform camelCase to snake_case for Medusa API (partial)
435
+ const medusaData: any = {};
436
+ if (data.firstName !== undefined) medusaData.first_name = data.firstName;
437
+ if (data.lastName !== undefined) medusaData.last_name = data.lastName;
438
+ if (data.company !== undefined) medusaData.company = data.company;
439
+ if (data.address1 !== undefined) medusaData.address_1 = data.address1;
440
+ if (data.address2 !== undefined) medusaData.address_2 = data.address2;
441
+ if (data.city !== undefined) medusaData.city = data.city;
442
+ if (data.province !== undefined) medusaData.province = data.province;
443
+ if (data.postalCode !== undefined) medusaData.postal_code = data.postalCode;
444
+ if (data.countryCode !== undefined) medusaData.country_code = data.countryCode;
445
+ if (data.phone !== undefined) medusaData.phone = data.phone;
446
+ if (data.metadata !== undefined) medusaData.metadata = data.metadata;
447
+
448
+ const { customer } = await this.sdk.store.customer.updateAddress(addressId, medusaData);
449
+
450
+ // Find the updated address by ID
451
+ const addresses = customer.addresses || [];
452
+ const address = addresses.find((addr: any) => addr.id === addressId);
453
+
454
+ if (!address) {
455
+ throw new Error(`Address ${addressId} not found after update`);
456
+ }
457
+
458
+ return transformAddress(address);
459
+ } catch (error: any) {
460
+ this.logger.error('Update address failed', error);
461
+ throw new ProviderAPIError(
462
+ `Failed to update address: ${error.message}`,
463
+ 'UPDATE_ADDRESS_FAILED',
464
+ error
465
+ );
466
+ }
467
+ }
468
+
469
+ async deleteAddress(addressId: string): Promise<void> {
470
+ this.logger.debug('deleteAddress', { addressId });
471
+
472
+ try {
473
+ await this.sdk.store.customer.deleteAddress(addressId);
474
+ this.logger.info('Address deleted', { addressId });
475
+ } catch (error: any) {
476
+ this.logger.error('Delete address failed', error);
477
+ throw new ProviderAPIError(
478
+ `Failed to delete address: ${error.message}`,
479
+ 'DELETE_ADDRESS_FAILED',
480
+ error
481
+ );
482
+ }
483
+ }
484
+
485
+ async deleteCustomerAccount(): Promise<void> {
486
+ this.logger.debug('deleteCustomerAccount');
487
+
488
+ // TODO: Implement when custom Medusa backend endpoint is ready
489
+ // DELETE /store/customers/me
490
+ throw new ProviderAPIError(
491
+ 'Delete customer account is not yet implemented. Requires custom Medusa backend endpoint.',
492
+ 'NOT_IMPLEMENTED',
493
+ undefined
494
+ );
495
+ }
496
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Fulfillment planner
3
+ *
4
+ * Plans an optimal fulfillment strategy for cart items given:
5
+ * - available stock locations
6
+ * - inventory levels per variant
7
+ * - an optional shipping address (for proximity sorting)
8
+ */
9
+
10
+ import type { CartItem, InventoryLevel, StockLocation, FulfillmentOption } from '@thorprovider/types';
11
+ import { calculateDistance, geocodeAddress, type Coordinates } from '../../utils/geolocation';
12
+
13
+ export interface FulfillmentGroup {
14
+ locationId: string;
15
+ locationName: string;
16
+ items: Array<{
17
+ variantId: string;
18
+ quantity: number;
19
+ productName: string;
20
+ }>;
21
+ shippingOptions: FulfillmentOption[];
22
+ }
23
+
24
+ export interface FulfillmentPlan {
25
+ canFulfill: boolean;
26
+ groups: FulfillmentGroup[];
27
+ shippingAddress?: Coordinates;
28
+ }
29
+
30
+ export interface PlanFulfillmentShippingAddress {
31
+ city?: string;
32
+ countryCode?: string;
33
+ postalCode?: string;
34
+ }
35
+
36
+ interface InventoryMap {
37
+ [variantId: string]: InventoryLevel[];
38
+ }
39
+
40
+ export async function planFulfillment(
41
+ cartItems: CartItem[],
42
+ stockLocations: StockLocation[],
43
+ inventoryLevels: InventoryMap,
44
+ shippingAddress?: PlanFulfillmentShippingAddress,
45
+ ): Promise<FulfillmentPlan> {
46
+ if (cartItems.length === 0) {
47
+ return { canFulfill: true, groups: [] };
48
+ }
49
+
50
+ let addressCoords: Coordinates | null = null;
51
+ if (shippingAddress) {
52
+ addressCoords = await geocodeAddress(shippingAddress);
53
+ }
54
+
55
+ const itemLocationMap = new Map<string, string[]>();
56
+
57
+ for (const item of cartItems) {
58
+ const variantId = item.merchandise.id;
59
+ const levels = inventoryLevels[variantId] ?? [];
60
+
61
+ const availableLocations = levels
62
+ .filter((level) => level.availableQuantity >= item.quantity)
63
+ .map((level) => level.locationId);
64
+
65
+ itemLocationMap.set(variantId, availableLocations);
66
+ }
67
+
68
+ const singleLocationPlan = findSingleLocationFulfillment(
69
+ cartItems,
70
+ stockLocations,
71
+ itemLocationMap,
72
+ addressCoords,
73
+ );
74
+
75
+ if (singleLocationPlan) {
76
+ return {
77
+ canFulfill: true,
78
+ groups: [singleLocationPlan],
79
+ shippingAddress: addressCoords ?? undefined,
80
+ };
81
+ }
82
+
83
+ const splitPlan = findSplitFulfillment(cartItems, stockLocations, inventoryLevels, addressCoords);
84
+
85
+ return {
86
+ canFulfill: splitPlan.groups.length > 0,
87
+ groups: splitPlan.groups,
88
+ shippingAddress: addressCoords ?? undefined,
89
+ };
90
+ }
91
+
92
+ function findSingleLocationFulfillment(
93
+ items: CartItem[],
94
+ locations: StockLocation[],
95
+ itemLocationMap: Map<string, string[]>,
96
+ shippingCoords: Coordinates | null,
97
+ ): FulfillmentGroup | null {
98
+ const firstVariantId = items[0]?.merchandise?.id;
99
+ if (!firstVariantId) return null;
100
+
101
+ const firstItemLocations = itemLocationMap.get(firstVariantId);
102
+ if (!firstItemLocations || firstItemLocations.length === 0) {
103
+ return null;
104
+ }
105
+
106
+ const locationCandidates = new Set<string>(firstItemLocations);
107
+
108
+ for (let i = 1; i < items.length; i++) {
109
+ const variantId = items[i]?.merchandise?.id;
110
+ if (!variantId) return null;
111
+
112
+ const itemLocations = itemLocationMap.get(variantId) ?? [];
113
+ const itemLocationsSet = new Set(itemLocations);
114
+
115
+ for (const loc of locationCandidates) {
116
+ if (!itemLocationsSet.has(loc)) {
117
+ locationCandidates.delete(loc);
118
+ }
119
+ }
120
+
121
+ if (locationCandidates.size === 0) return null;
122
+ }
123
+
124
+ let selectedLocationId = Array.from(locationCandidates)[0];
125
+
126
+ if (shippingCoords) {
127
+ let minDistance = Infinity;
128
+
129
+ for (const locId of locationCandidates) {
130
+ const location = locations.find((l) => l.id === locId);
131
+ const lat = location?.metadata?.lat;
132
+ const lng = location?.metadata?.lng;
133
+ if (typeof lat !== 'number' || typeof lng !== 'number') continue;
134
+
135
+ const distance = calculateDistance(shippingCoords, { lat, lng });
136
+ if (distance < minDistance) {
137
+ minDistance = distance;
138
+ selectedLocationId = locId;
139
+ }
140
+ }
141
+ }
142
+
143
+ const selectedLocation = locations.find((l) => l.id === selectedLocationId);
144
+ if (!selectedLocation) return null;
145
+
146
+ return {
147
+ locationId: selectedLocationId,
148
+ locationName: selectedLocation.name,
149
+ items: items.map((item) => ({
150
+ variantId: item.merchandise.id,
151
+ quantity: item.quantity,
152
+ productName: item.merchandise.product.title,
153
+ })),
154
+ shippingOptions: [],
155
+ };
156
+ }
157
+
158
+ function findSplitFulfillment(
159
+ items: CartItem[],
160
+ locations: StockLocation[],
161
+ inventoryLevels: InventoryMap,
162
+ shippingCoords: Coordinates | null,
163
+ ): { groups: FulfillmentGroup[] } {
164
+ const groups: FulfillmentGroup[] = [];
165
+ const assignedVariantIds = new Set<string>();
166
+
167
+ const sortedLocations = sortLocationsByProximity(locations, shippingCoords);
168
+
169
+ for (const location of sortedLocations) {
170
+ const groupItems: FulfillmentGroup['items'] = [];
171
+
172
+ for (const item of items) {
173
+ const variantId = item.merchandise.id;
174
+ if (assignedVariantIds.has(variantId)) continue;
175
+
176
+ const levels = inventoryLevels[variantId] ?? [];
177
+ const levelAtLocation = levels.find((l) => l.locationId === location.id);
178
+
179
+ if (levelAtLocation && levelAtLocation.availableQuantity >= item.quantity) {
180
+ groupItems.push({
181
+ variantId,
182
+ quantity: item.quantity,
183
+ productName: item.merchandise.product.title,
184
+ });
185
+ assignedVariantIds.add(variantId);
186
+ }
187
+ }
188
+
189
+ if (groupItems.length > 0) {
190
+ groups.push({
191
+ locationId: location.id,
192
+ locationName: location.name,
193
+ items: groupItems,
194
+ shippingOptions: [],
195
+ });
196
+ }
197
+
198
+ if (assignedVariantIds.size === items.length) {
199
+ break;
200
+ }
201
+ }
202
+
203
+ return { groups };
204
+ }
205
+
206
+ function sortLocationsByProximity(
207
+ locations: StockLocation[],
208
+ shippingCoords: Coordinates | null,
209
+ ): StockLocation[] {
210
+ if (!shippingCoords) return [...locations];
211
+
212
+ return [...locations].sort((a, b) => {
213
+ const distA =
214
+ typeof a.metadata?.lat === 'number' && typeof a.metadata?.lng === 'number'
215
+ ? calculateDistance(shippingCoords, { lat: a.metadata.lat, lng: a.metadata.lng })
216
+ : Infinity;
217
+
218
+ const distB =
219
+ typeof b.metadata?.lat === 'number' && typeof b.metadata?.lng === 'number'
220
+ ? calculateDistance(shippingCoords, { lat: b.metadata.lat, lng: b.metadata.lng })
221
+ : Infinity;
222
+
223
+ return distA - distB;
224
+ });
225
+ }