@thorprovider/adapters 5.0.9 → 5.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { describe, expect, it, beforeAll } from 'vitest'
25
+ import Medusa from '@medusajs/js-sdk'
25
26
  import { MedusaProvider } from './index'
26
27
  import { MedusaAdminClient } from './admin'
27
28
 
@@ -357,6 +358,7 @@ if (!runLive) {
357
358
  // --------------------------------------------------------------------------
358
359
  // Group E: Admin Standard Medusa Endpoints (via SDK with Basic auth)
359
360
  // --------------------------------------------------------------------------
361
+ // (See the separate change-B acceptance block below.)
360
362
  describe('Group E: Admin Standard Medusa Endpoints', () => {
361
363
  let provider: MedusaProvider;
362
364
 
@@ -410,3 +412,69 @@ if (!runLive) {
410
412
  })
411
413
  });
412
414
  }
415
+
416
+ // --------------------------------------------------------------------------
417
+ // Change B acceptance — native pagination-bound probes + directed product
418
+ // lookup. Runs independently of MEDUSA_SMOKE_TEST; gated by
419
+ // MEDUSA_B_ACCEPTANCE=1. Requires a running backend and the acceptance fixture
420
+ // env (never printed): MEDUSA_BASE_URL, MEDUSA_PUBLISHABLE_KEY,
421
+ // MEDUSA_REGION_ID, MEDUSA_SALES_CHANNEL_ID, B_ACCEPTANCE_PRODUCT_ID,
422
+ // B_ACCEPTANCE_PRODUCT_HANDLE.
423
+ // --------------------------------------------------------------------------
424
+ const runBAcceptance = process.env.MEDUSA_B_ACCEPTANCE === '1'
425
+
426
+ describe('Medusa change-B acceptance — pagination bound + directed lookup', () => {
427
+ it.skipIf(!runBAcceptance)(
428
+ 'accepts native listing bounds 1/50/100/200/1000 (configured bound, not a claimed max)',
429
+ async () => {
430
+ const baseUrl = requiredEnv('MEDUSA_BASE_URL')
431
+ const publishableKey = requiredEnv('MEDUSA_PUBLISHABLE_KEY')
432
+ const regionId = requiredEnv('MEDUSA_REGION_ID')
433
+ const salesChannelId = requiredEnv('MEDUSA_SALES_CHANNEL_ID')
434
+
435
+ const sdk = new Medusa({ baseUrl, publishableKey })
436
+ for (const limit of [1, 50, 100, 200, 1000]) {
437
+ const { products, count } = await sdk.store.product.list({
438
+ limit,
439
+ region_id: regionId,
440
+ sales_channel_id: [salesChannelId],
441
+ fields: '+variants.calculated_price',
442
+ } as any)
443
+ expect(Array.isArray(products)).toBe(true)
444
+ expect(products.length).toBeLessThanOrEqual(limit)
445
+ expect(typeof count === 'number' || count === undefined).toBe(true)
446
+ }
447
+ },
448
+ )
449
+
450
+ it.skipIf(!runBAcceptance)(
451
+ 'resolves the acceptance product by handle and by id with real variant prices',
452
+ async () => {
453
+ const baseUrl = requiredEnv('MEDUSA_BASE_URL')
454
+ const publishableKey = requiredEnv('MEDUSA_PUBLISHABLE_KEY')
455
+ const regionId = requiredEnv('MEDUSA_REGION_ID')
456
+ const productId = requiredEnv('B_ACCEPTANCE_PRODUCT_ID')
457
+ const handle = requiredEnv('B_ACCEPTANCE_PRODUCT_HANDLE')
458
+
459
+ const provider = new MedusaProvider({
460
+ baseUrl,
461
+ publishableKey,
462
+ regionId,
463
+ currencyCode: 'usd',
464
+ })
465
+
466
+ const byHandle = await provider.getProduct(handle)
467
+ expect(byHandle?.handle).toBe(handle)
468
+ expect(byHandle?.variants.length).toBeGreaterThanOrEqual(2)
469
+
470
+ const byId = await provider.getProductById(productId)
471
+ expect(byId?.id).toBe(productId)
472
+
473
+ const amounts = (byHandle?.variants ?? [])
474
+ .map((v) => v.price.amount)
475
+ .filter((amount) => amount !== '')
476
+ expect(amounts).toContain('11.11')
477
+ expect(amounts).toContain('22.22')
478
+ },
479
+ )
480
+ })
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ transformProduct,
4
+ transformProductPrice,
5
+ transformProductVariant,
6
+ } from './transforms'
7
+
8
+ const BASE_VARIANT = {
9
+ id: 'variant_s',
10
+ title: 'Small',
11
+ sku: 'B-ACC-S',
12
+ manage_inventory: false,
13
+ inventory_quantity: 5,
14
+ calculated_price: { calculated_amount: 11.11, currency_code: 'usd' },
15
+ options: [{ option: { title: 'Size' }, value: 'Small' }],
16
+ thumbnail: 'https://example.com/small.png',
17
+ }
18
+
19
+ describe('transformProductPrice (change B, decision 6)', () => {
20
+ it('preserves a valid zero in major units', () => {
21
+ expect(transformProductPrice(0, 'usd')).toEqual({
22
+ amount: '0.00',
23
+ currencyCode: 'USD',
24
+ })
25
+ })
26
+
27
+ it('carries an absent price as an empty amount, never a fabricated zero', () => {
28
+ expect(transformProductPrice(undefined, 'usd')).toEqual({
29
+ amount: '',
30
+ currencyCode: 'USD',
31
+ })
32
+ expect(transformProductPrice(null, 'usd').amount).toBe('')
33
+ })
34
+
35
+ it('rejects a non-finite amount before display', () => {
36
+ expect(transformProductPrice(Number.NaN, 'usd').amount).toBe('')
37
+ expect(transformProductPrice(Number.POSITIVE_INFINITY, 'usd').amount).toBe('')
38
+ })
39
+
40
+ it('never hardcodes USD: an unresolved currency stays empty', () => {
41
+ expect(transformProductPrice(11.11, undefined)).toEqual({
42
+ amount: '11.11',
43
+ currencyCode: '',
44
+ })
45
+ })
46
+ })
47
+
48
+ describe('transformProductVariant', () => {
49
+ it('maps real options, availability, SKU and the backend currency', () => {
50
+ const variant = transformProductVariant(BASE_VARIANT)
51
+ expect(variant.id).toBe('variant_s')
52
+ expect(variant.selectedOptions).toEqual([{ name: 'Size', value: 'Small' }])
53
+ expect(variant.availableForSale).toBe(true)
54
+ expect(variant.sku).toBe('B-ACC-S')
55
+ expect(variant.price).toEqual({ amount: '11.11', currencyCode: 'USD' })
56
+ })
57
+
58
+ it('keeps stock availability when the price is unresolved (price is separate)', () => {
59
+ const variant = transformProductVariant({
60
+ ...BASE_VARIANT,
61
+ calculated_price: undefined,
62
+ prices: [],
63
+ })
64
+ expect(variant.availableForSale).toBe(true)
65
+ expect(variant.price.amount).toBe('')
66
+ })
67
+
68
+ it('preserves a valid zero price', () => {
69
+ const variant = transformProductVariant({
70
+ ...BASE_VARIANT,
71
+ calculated_price: { calculated_amount: 0, currency_code: 'eur' },
72
+ })
73
+ expect(variant.price).toEqual({ amount: '0.00', currencyCode: 'EUR' })
74
+ })
75
+
76
+ it('omits SKU when the merchant does not supply one (optional descriptive)', () => {
77
+ const variant = transformProductVariant({ ...BASE_VARIANT, sku: undefined })
78
+ expect(variant.sku).toBeUndefined()
79
+ expect(variant.id).toBe('variant_s')
80
+ })
81
+ })
82
+
83
+ describe('transformProduct', () => {
84
+ it('builds the price range from resolvable variant prices', () => {
85
+ const product = transformProduct({
86
+ id: 'prod_1',
87
+ handle: 'b-acceptance-product',
88
+ title: 'B Acceptance Product',
89
+ description: '',
90
+ thumbnail: 'https://example.com/p.png',
91
+ images: [],
92
+ options: [],
93
+ variants: [
94
+ BASE_VARIANT,
95
+ {
96
+ ...BASE_VARIANT,
97
+ id: 'variant_l',
98
+ title: 'Large',
99
+ calculated_price: { calculated_amount: 22.22, currency_code: 'usd' },
100
+ },
101
+ ],
102
+ })
103
+ expect(product.priceRange.minVariantPrice).toEqual({
104
+ amount: '11.11',
105
+ currencyCode: 'USD',
106
+ })
107
+ expect(product.priceRange.maxVariantPrice).toEqual({
108
+ amount: '22.22',
109
+ currencyCode: 'USD',
110
+ })
111
+ })
112
+
113
+ it('maps a present thumbnail and images to featuredImage and images', () => {
114
+ const product = transformProduct({
115
+ id: 'prod_media',
116
+ handle: 'with-media',
117
+ title: 'With Media',
118
+ description: '',
119
+ thumbnail: 'https://example.com/cover.png',
120
+ images: [
121
+ { url: 'https://example.com/a.png' },
122
+ { url: 'https://example.com/b.png' },
123
+ ],
124
+ options: [],
125
+ variants: [],
126
+ })
127
+
128
+ expect(product.featuredImage).toEqual({
129
+ url: 'https://example.com/cover.png',
130
+ altText: 'With Media',
131
+ width: 800,
132
+ height: 800,
133
+ })
134
+ expect(product.images?.map((img) => img.url)).toEqual([
135
+ 'https://example.com/a.png',
136
+ 'https://example.com/b.png',
137
+ ])
138
+ })
139
+
140
+ it('omits featuredImage and images when the backend supplies neither (never an empty src)', () => {
141
+ const product = transformProduct({
142
+ id: 'prod_no_media',
143
+ handle: 'no-media',
144
+ title: 'No Media',
145
+ description: '',
146
+ thumbnail: undefined,
147
+ images: undefined,
148
+ options: [],
149
+ variants: [],
150
+ })
151
+
152
+ expect(product.featuredImage).toBeUndefined()
153
+ expect(product.images).toEqual([])
154
+ expect(product.images?.some((img) => img.url === '')).toBe(false)
155
+ expect(product.featuredImage?.url).toBeUndefined()
156
+ })
157
+
158
+ it('does not fabricate a zero range when no variant is priced', () => {
159
+ const product = transformProduct({
160
+ id: 'prod_1',
161
+ handle: 'sin-precio',
162
+ title: 'Sin precio',
163
+ description: '',
164
+ images: [],
165
+ options: [],
166
+ variants: [
167
+ {
168
+ ...BASE_VARIANT,
169
+ calculated_price: undefined,
170
+ prices: [],
171
+ },
172
+ ],
173
+ })
174
+ expect(product.priceRange.minVariantPrice.amount).toBe('')
175
+ expect(product.priceRange.maxVariantPrice.amount).toBe('')
176
+ })
177
+ })
@@ -49,6 +49,44 @@ function transformMoney(amount: number | null | undefined, currencyCode: string
49
49
  };
50
50
  }
51
51
 
52
+ /**
53
+ * Backend-authoritative money (change B, decision 9).
54
+ *
55
+ * A finite backend amount is carried through unchanged, including a legitimate
56
+ * zero; a missing or non-finite amount becomes an empty `amount` string instead
57
+ * of an invented zero, so the runtime boundary can surface the backend gap
58
+ * rather than fabricate a total. The currency comes from the backend and is
59
+ * never defaulted to a hardcoded `USD`.
60
+ */
61
+ function transformAmount(
62
+ amount: number | null | undefined,
63
+ currencyCode?: string | null,
64
+ ): Money {
65
+ const currencyCodeUpper = (currencyCode ?? '').toUpperCase();
66
+ if (typeof amount !== 'number' || !Number.isFinite(amount)) {
67
+ return { amount: '', currencyCode: currencyCodeUpper };
68
+ }
69
+ return { amount: amount.toFixed(2), currencyCode: currencyCodeUpper };
70
+ }
71
+
72
+ /**
73
+ * Product price projection (change B, decision 6).
74
+ *
75
+ * Unlike cart/order money, a product variant price is NOT fabricated: an
76
+ * absent or non-finite backend amount is carried as an empty `amount` string
77
+ * so the runtime boundary (`parseMoney`) can reject it and mark the variant
78
+ * unavailable instead of showing a fake zero. A backend-resolved valid zero is
79
+ * preserved as `"0.00"`. The currency comes from the backend or the configured
80
+ * context — never a hardcoded `USD` (an unresolved currency stays empty and is
81
+ * likewise rejected downstream).
82
+ */
83
+ export function transformProductPrice(
84
+ amount: number | null | undefined,
85
+ currencyCode?: string | null,
86
+ ): Money {
87
+ return transformAmount(amount, currencyCode);
88
+ }
89
+
52
90
  /**
53
91
  * Transform Medusa image to unified Image
54
92
  */
@@ -126,43 +164,51 @@ export function transformProductOption(option: any): ProductOption {
126
164
  */
127
165
  export function transformProductVariant(
128
166
  variant: any,
129
- currencyCode: string = 'USD'
167
+ currencyCode?: string
130
168
  ): ProductVariant {
131
169
  let priceAmount: number | null | undefined;
132
- let priceCurrency: string = currencyCode;
133
-
170
+ let priceCurrency: string | undefined = currencyCode;
171
+
134
172
  // Priority 1: Use calculated_price if available (from region-based pricing)
135
173
  if (variant.calculated_price) {
136
174
  priceAmount = variant.calculated_price.calculated_amount;
137
- priceCurrency = variant.calculated_price.currency_code || currencyCode;
138
- }
139
- // Priority 2: Fallback to prices array
175
+ priceCurrency = variant.calculated_price.currency_code ?? currencyCode;
176
+ }
177
+ // Priority 2: Fallback to prices array (match the context currency, else the first)
140
178
  else if (variant.prices && variant.prices.length > 0) {
141
- const price = variant.prices.find((p: any) => p.currency_code === currencyCode.toLowerCase());
179
+ const target = currencyCode?.toLowerCase();
180
+ const price =
181
+ (target
182
+ ? variant.prices.find((p: any) => p.currency_code === target)
183
+ : undefined) ?? variant.prices[0];
142
184
  priceAmount = price?.amount;
143
- priceCurrency = price?.currency_code || currencyCode;
185
+ priceCurrency = price?.currency_code ?? currencyCode;
144
186
  }
145
-
187
+
146
188
  // Determine if variant is available for sale
147
189
  // Following Medusa docs: https://docs.medusajs.com/resources/storefront-development/products/inventory
148
190
  // A variant is in stock if:
149
191
  // 1. manage_inventory is false (inventory not tracked) - always in stock
150
192
  // 2. inventory_quantity > 0 (has stock)
151
193
  // 3. allow_backorder is true (can order even without stock)
194
+ // NOTE: this is stock availability only. Price availability is a separate
195
+ // concern resolved by the runtime boundary; an unpriced variant keeps its
196
+ // stock-derived availability and carries an empty amount.
152
197
  const isAvailable = variant.manage_inventory === false
153
198
  ? true
154
199
  : (variant.inventory_quantity ?? 0) > 0 || variant.allow_backorder === true;
155
-
200
+
156
201
  return {
157
202
  id: variant.id!,
158
203
  title: variant.title ?? 'Default',
204
+ // SKU is optional descriptive data only — never API/purchase identity.
159
205
  sku: variant.sku ?? undefined,
160
206
  availableForSale: isAvailable,
161
207
  selectedOptions: variant.options?.map((opt: any) => ({
162
208
  name: (opt as any).option?.title ?? '',
163
209
  value: opt.value ?? '',
164
210
  })) ?? [],
165
- price: transformMoney(priceAmount, priceCurrency),
211
+ price: transformProductPrice(priceAmount, priceCurrency),
166
212
  compareAtPrice: undefined, // Medusa doesn't have compare-at pricing
167
213
  quantityAvailable: variant.manage_inventory ? variant.inventory_quantity : undefined,
168
214
  image: variant.thumbnail
@@ -176,14 +222,22 @@ export function transformProductVariant(
176
222
  */
177
223
  export function transformProduct(
178
224
  product: any,
179
- currencyCode: string = 'USD'
225
+ currencyCode?: string
180
226
  ): Product {
181
227
  const variants = product.variants?.map((v: any) => transformProductVariant(v, currencyCode)) ?? [];
182
-
183
- // Calculate price range
184
- const prices = variants.map((v: any) => parseFloat(v.price.amount));
185
- const minPrice = Math.min(...prices);
186
- const maxPrice = Math.max(...prices);
228
+
229
+ // Price range from resolvable variant prices only. An unresolved price is
230
+ // carried as an empty amount (never fabricated as zero); if no variant is
231
+ // priced, the range is unresolved too and the runtime marks it unavailable.
232
+ const validPrices = variants
233
+ .map((v: ProductVariant) => v.price)
234
+ .filter((money: Money) => money.amount !== '' && Number.isFinite(Number(money.amount)))
235
+ .map((money: Money) => Number(money.amount));
236
+ const rangeCurrency =
237
+ variants.find((v: ProductVariant) => v.price.currencyCode)?.price.currencyCode ??
238
+ (currencyCode ?? '').toUpperCase();
239
+ const minPrice = validPrices.length > 0 ? Math.min(...validPrices) : undefined;
240
+ const maxPrice = validPrices.length > 0 ? Math.max(...validPrices) : undefined;
187
241
 
188
242
  return {
189
243
  id: product.id!,
@@ -206,10 +260,16 @@ export function transformProduct(
206
260
 
207
261
  priceRange: {
208
262
  // Build Money directly from already-resolved major-unit prices.
209
- // Do NOT pass through transformMoney (which would call toFixed on already-correct values)
210
- // and do NOT multiply by 100 (that was a compensatory hack for the old /100 bug).
211
- minVariantPrice: { amount: (Number.isFinite(minPrice) ? minPrice : 0).toFixed(2), currencyCode: currencyCode.toUpperCase() },
212
- maxVariantPrice: { amount: (Number.isFinite(maxPrice) ? maxPrice : 0).toFixed(2), currencyCode: currencyCode.toUpperCase() },
263
+ // Do NOT multiply by 100 (that was a compensatory hack for the old /100
264
+ // bug) and do NOT fabricate a zero for an unresolved price.
265
+ minVariantPrice: {
266
+ amount: minPrice !== undefined ? minPrice.toFixed(2) : '',
267
+ currencyCode: rangeCurrency,
268
+ },
269
+ maxVariantPrice: {
270
+ amount: maxPrice !== undefined ? maxPrice.toFixed(2) : '',
271
+ currencyCode: rangeCurrency,
272
+ },
213
273
  },
214
274
 
215
275
  collections: product.collection ? [{
@@ -250,47 +310,30 @@ function resolveMedusaCartItemCurrencyCode(item: any, cartCurrencyCode?: string)
250
310
  }
251
311
 
252
312
  /**
253
- * Resolve the total amount for a Medusa cart item.
254
- * All amounts from Medusa v2 are in major units (dollars, euros).
313
+ * Resolve the authoritative line total a Medusa cart line reports.
314
+ *
315
+ * Only backend-supplied totals are used (`total` after discounts, then
316
+ * `item_total`, then `subtotal`); the adapter never multiplies a unit price by
317
+ * a quantity to invent a line total. A legitimate `0` is preserved and an
318
+ * absent total is returned as `undefined` so the money transform carries an
319
+ * empty amount instead of fabricating a value.
255
320
  */
256
- function resolveMedusaCartItemTotal(item: any): number {
257
- const quantity = item?.quantity ?? 1;
258
- const safeQuantity = typeof quantity === 'number' && quantity > 0 ? quantity : 1;
259
-
260
- const totalCandidates = [
261
- item?.total,
262
- item?.item_total,
263
- item?.original_total,
264
- item?.subtotal,
265
- item?.item_subtotal,
266
- item?.original_subtotal,
267
- ];
268
-
269
- for (const candidate of totalCandidates) {
270
- if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
271
- return candidate;
272
- }
273
- }
274
-
275
- const unitCandidates = [
276
- item?.unit_price,
277
- item?.original_unit_price,
278
- ];
321
+ function resolveAuthoritativeLineTotal(item: any): number | undefined {
322
+ const candidates = [item?.total, item?.item_total, item?.subtotal];
279
323
 
280
- for (const candidate of unitCandidates) {
281
- if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
282
- return candidate * safeQuantity;
324
+ for (const candidate of candidates) {
325
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
326
+ return candidate;
283
327
  }
284
328
  }
285
329
 
286
- return 0;
330
+ return undefined;
287
331
  }
288
332
 
289
333
  export function transformCartItem(item: any, cartCurrencyCode?: string): CartItem {
290
334
  const variant = item.variant;
291
335
  const product = variant?.product;
292
336
  const currencyCode = resolveMedusaCartItemCurrencyCode(item, cartCurrencyCode);
293
- const totalCents = resolveMedusaCartItemTotal(item);
294
337
 
295
338
  // Medusa v2 store cart items return flat fields (product_title, variant_title,
296
339
  // product_handle, thumbnail) instead of a nested variant.product object.
@@ -305,7 +348,9 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
305
348
  id: item.id,
306
349
  quantity: item.quantity ?? 1,
307
350
  cost: {
308
- totalAmount: transformMoney(totalCents, currencyCode),
351
+ totalAmount: transformAmount(resolveAuthoritativeLineTotal(item), currencyCode),
352
+ // Backend unit price, carried so the display never derives it locally.
353
+ unitPrice: transformAmount(item?.unit_price, currencyCode),
309
354
  },
310
355
  merchandise: {
311
356
  id: merchandiseId,
@@ -326,26 +371,53 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
326
371
  }
327
372
 
328
373
  /**
329
- * Transform Medusa Cart to unified Cart
374
+ * Transform Medusa Cart to unified Cart.
375
+ *
376
+ * Totals are backend-authoritative (change B, decision 9): subtotal, discount,
377
+ * tax, shipping and total are carried straight from the cart response, a
378
+ * legitimate zero is preserved, and no amount is derived locally.
379
+ *
380
+ * `subtotalAmount` prefers the backend's `item_subtotal` (the sum of the line
381
+ * items, excluding shipping and tax). Medusa's native `subtotal` also folds in
382
+ * `shipping_subtotal`; presenting that as the commercial subtotal would
383
+ * double-count shipping. When `item_subtotal` is absent the native `subtotal`
384
+ * is used unchanged, preserving backward compatibility.
385
+ *
386
+ * The cart's sales channel, region and currency are carried so the runtime can
387
+ * enforce the validated single-store context, and `completed_at` is carried so
388
+ * a completed cart can be reconciled.
330
389
  */
331
390
  export function transformCart(cart: any): Cart {
332
- const currencyCode = cart.currency_code ?? 'usd';
333
-
391
+ const currencyCode = cart.currency_code;
392
+
334
393
  return {
335
394
  id: cart.id,
336
395
  checkoutUrl: `/checkout/${cart.id}`, // Medusa doesn't have a direct checkout URL
337
396
  cost: {
338
- subtotalAmount: transformMoney(cart.subtotal, currencyCode),
339
- totalAmount: transformMoney(cart.total, currencyCode),
340
- totalTaxAmount: transformMoney(cart.tax_total, currencyCode),
397
+ subtotalAmount: transformAmount(
398
+ typeof cart.item_subtotal === 'number' ? cart.item_subtotal : cart.subtotal,
399
+ currencyCode,
400
+ ),
401
+ discountAmount: transformAmount(cart.discount_total, currencyCode),
402
+ totalTaxAmount: transformAmount(cart.tax_total, currencyCode),
403
+ shippingAmount: transformAmount(cart.shipping_total, currencyCode),
404
+ totalAmount: transformAmount(cart.total, currencyCode),
341
405
  },
342
406
  lines: cart.items?.map((item: any) => transformCartItem(item, currencyCode)) ?? [],
343
407
  totalQuantity: cart.items?.reduce((sum: any, item: any) => sum + (item.quantity ?? 0), 0) ?? 0,
408
+ salesChannelId: cart.sales_channel_id ?? undefined,
409
+ regionId: cart.region_id ?? undefined,
410
+ currencyCode:
411
+ typeof currencyCode === 'string' ? currencyCode.toUpperCase() : undefined,
412
+ selectedShippingMethodId: cart.shipping_methods?.[0]?.id ?? undefined,
413
+ selectedShippingOptionId:
414
+ cart.shipping_methods?.[0]?.shipping_option_id ?? undefined,
344
415
  completedAt: cart.completed_at
345
416
  ? (typeof cart.completed_at === 'string'
346
417
  ? cart.completed_at
347
418
  : cart.completed_at?.toISOString())
348
419
  : undefined,
420
+ metadata: cart.metadata ?? undefined,
349
421
  };
350
422
  }
351
423
 
@@ -477,8 +549,8 @@ export function transformOrderItem(item: any, currencyCode: string): OrderItem {
477
549
  title: item.product_title ?? item.title ?? '',
478
550
  variantTitle: item.variant_title ?? variant?.title ?? undefined,
479
551
  quantity: item.quantity ?? 1,
480
- price: transformMoney(item.unit_price, currencyCode),
481
- total: transformMoney(item.total, currencyCode),
552
+ price: transformAmount(item.unit_price, currencyCode),
553
+ total: transformAmount(item.total, currencyCode),
482
554
  sku: variant?.sku ?? undefined,
483
555
  image: product?.thumbnail || item.thumbnail
484
556
  ? transformImage(product?.thumbnail || item.thumbnail, item.product_title ?? '')
@@ -556,12 +628,19 @@ export function transformOrder(order: any): Order {
556
628
  .filter((item: any) => item != null) // Filter out null/undefined items
557
629
  .map((item: any) => transformOrderItem(item, currencyCode)) ?? [],
558
630
 
559
- // Pricing
560
- subtotal: transformMoney(order.subtotal, currencyCode),
561
- total: transformMoney(order.total, currencyCode),
562
- tax: transformMoney(order.tax_total, currencyCode),
563
- shipping: transformMoney(order.shipping_total, currencyCode),
564
- discount: transformMoney(order.discount_total, currencyCode),
631
+ // Pricing — backend-authoritative; a legitimate zero is preserved and no
632
+ // amount is derived locally. `subtotal` prefers the backend's
633
+ // `item_subtotal` (lines only, excluding shipping/tax); Medusa's native
634
+ // `subtotal` folds in `shipping_subtotal`, so falling back to it is only
635
+ // for installs that do not supply `item_subtotal`.
636
+ subtotal: transformAmount(
637
+ typeof order.item_subtotal === 'number' ? order.item_subtotal : order.subtotal,
638
+ currencyCode,
639
+ ),
640
+ total: transformAmount(order.total, currencyCode),
641
+ tax: transformAmount(order.tax_total, currencyCode),
642
+ shipping: transformAmount(order.shipping_total, currencyCode),
643
+ discount: transformAmount(order.discount_total, currencyCode),
565
644
 
566
645
  // Addresses (transformed to camelCase)
567
646
  shippingAddress: order.shipping_address