@thorprovider/adapters 5.0.10 → 5.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/AGENTS.md +1 -1
  2. package/dist/index.d.mts +376 -16
  3. package/dist/index.d.ts +376 -16
  4. package/dist/index.js +1687 -528
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1680 -530
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/core/errors.ts +31 -0
  10. package/src/factory/index.ts +16 -6
  11. package/src/factory/provider-types.test.ts +32 -0
  12. package/src/factory/provider-types.ts +176 -0
  13. package/src/index.ts +6 -4
  14. package/src/providers/index.ts +1 -1
  15. package/src/providers/medusa/auth.test.ts +148 -25
  16. package/src/providers/medusa/auth.ts +129 -16
  17. package/src/providers/medusa/available-filters.test.ts +280 -0
  18. package/src/providers/medusa/catalog-facet-support.test.ts +715 -0
  19. package/src/providers/medusa/catalog-query.test.ts +422 -0
  20. package/src/providers/medusa/catalog-sort.test.ts +175 -0
  21. package/src/providers/medusa/customer-orders.test.ts +142 -0
  22. package/src/providers/medusa/index.test.ts +480 -0
  23. package/src/providers/medusa/index.ts +1592 -538
  24. package/src/providers/medusa/live-smoke.test.ts +68 -0
  25. package/src/providers/medusa/transforms.test.ts +222 -0
  26. package/src/providers/medusa/transforms.ts +152 -66
  27. package/src/providers/mock/catalog-query.test.ts +491 -0
  28. package/src/providers/mock/index.test.ts +66 -0
  29. package/src/providers/mock/index.ts +43 -1
  30. package/src/providers/mock/mock.ts +513 -3
  31. package/src/types/completion.ts +57 -0
  32. package/src/types/index.ts +7 -0
  33. package/.turbo/turbo-build.log +0 -18
  34. package/.turbo/turbo-test.log +0 -372
  35. package/.turbo/turbo-type-check.log +0 -0
  36. package/dist/.tsbuildinfo +0 -1
  37. package/publish.log +0 -93
@@ -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,222 @@
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
+
178
+ it('maps the backend category relation, preserving hierarchy', () => {
179
+ const product = transformProduct({
180
+ id: 'prod_cat',
181
+ handle: 'with-categories',
182
+ title: 'With Categories',
183
+ description: '',
184
+ images: [],
185
+ options: [],
186
+ variants: [],
187
+ categories: [
188
+ { id: 'cat_tools', name: 'Tools', handle: 'tools' },
189
+ {
190
+ id: 'cat_drills',
191
+ name: 'Drills',
192
+ handle: 'drills',
193
+ parent_category_id: 'cat_tools',
194
+ },
195
+ ],
196
+ })
197
+
198
+ expect(product.categories).toEqual([
199
+ { id: 'cat_tools', name: 'Tools', handle: 'tools', parentCategoryId: undefined },
200
+ {
201
+ id: 'cat_drills',
202
+ name: 'Drills',
203
+ handle: 'drills',
204
+ parentCategoryId: 'cat_tools',
205
+ },
206
+ ])
207
+ })
208
+
209
+ it('omits categories when the backend supplies none (never fabricated)', () => {
210
+ const product = transformProduct({
211
+ id: 'prod_no_cat',
212
+ handle: 'no-categories',
213
+ title: 'No Categories',
214
+ description: '',
215
+ images: [],
216
+ options: [],
217
+ variants: [],
218
+ })
219
+
220
+ expect(product.categories).toBeUndefined()
221
+ })
222
+ })
@@ -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 ? [{
@@ -218,6 +278,13 @@ export function transformProduct(
218
278
  handle: product.collection.handle ?? '',
219
279
  }] : undefined,
220
280
 
281
+ categories: product.categories?.map((category: any) => ({
282
+ id: category.id,
283
+ name: category.name ?? '',
284
+ handle: category.handle ?? '',
285
+ parentCategoryId: category.parent_category_id ?? undefined,
286
+ })),
287
+
221
288
  productType: product.type?.value ?? undefined,
222
289
  vendor: undefined, // Medusa doesn't have vendor field
223
290
  tags: product.tags?.map((t: any) => t.value ?? '') ?? [],
@@ -250,47 +317,30 @@ function resolveMedusaCartItemCurrencyCode(item: any, cartCurrencyCode?: string)
250
317
  }
251
318
 
252
319
  /**
253
- * Resolve the total amount for a Medusa cart item.
254
- * All amounts from Medusa v2 are in major units (dollars, euros).
320
+ * Resolve the authoritative line total a Medusa cart line reports.
321
+ *
322
+ * Only backend-supplied totals are used (`total` after discounts, then
323
+ * `item_total`, then `subtotal`); the adapter never multiplies a unit price by
324
+ * a quantity to invent a line total. A legitimate `0` is preserved and an
325
+ * absent total is returned as `undefined` so the money transform carries an
326
+ * empty amount instead of fabricating a value.
255
327
  */
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
- ];
328
+ function resolveAuthoritativeLineTotal(item: any): number | undefined {
329
+ const candidates = [item?.total, item?.item_total, item?.subtotal];
279
330
 
280
- for (const candidate of unitCandidates) {
281
- if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
282
- return candidate * safeQuantity;
331
+ for (const candidate of candidates) {
332
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
333
+ return candidate;
283
334
  }
284
335
  }
285
336
 
286
- return 0;
337
+ return undefined;
287
338
  }
288
339
 
289
340
  export function transformCartItem(item: any, cartCurrencyCode?: string): CartItem {
290
341
  const variant = item.variant;
291
342
  const product = variant?.product;
292
343
  const currencyCode = resolveMedusaCartItemCurrencyCode(item, cartCurrencyCode);
293
- const totalCents = resolveMedusaCartItemTotal(item);
294
344
 
295
345
  // Medusa v2 store cart items return flat fields (product_title, variant_title,
296
346
  // product_handle, thumbnail) instead of a nested variant.product object.
@@ -305,7 +355,9 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
305
355
  id: item.id,
306
356
  quantity: item.quantity ?? 1,
307
357
  cost: {
308
- totalAmount: transformMoney(totalCents, currencyCode),
358
+ totalAmount: transformAmount(resolveAuthoritativeLineTotal(item), currencyCode),
359
+ // Backend unit price, carried so the display never derives it locally.
360
+ unitPrice: transformAmount(item?.unit_price, currencyCode),
309
361
  },
310
362
  merchandise: {
311
363
  id: merchandiseId,
@@ -326,26 +378,53 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
326
378
  }
327
379
 
328
380
  /**
329
- * Transform Medusa Cart to unified Cart
381
+ * Transform Medusa Cart to unified Cart.
382
+ *
383
+ * Totals are backend-authoritative (change B, decision 9): subtotal, discount,
384
+ * tax, shipping and total are carried straight from the cart response, a
385
+ * legitimate zero is preserved, and no amount is derived locally.
386
+ *
387
+ * `subtotalAmount` prefers the backend's `item_subtotal` (the sum of the line
388
+ * items, excluding shipping and tax). Medusa's native `subtotal` also folds in
389
+ * `shipping_subtotal`; presenting that as the commercial subtotal would
390
+ * double-count shipping. When `item_subtotal` is absent the native `subtotal`
391
+ * is used unchanged, preserving backward compatibility.
392
+ *
393
+ * The cart's sales channel, region and currency are carried so the runtime can
394
+ * enforce the validated single-store context, and `completed_at` is carried so
395
+ * a completed cart can be reconciled.
330
396
  */
331
397
  export function transformCart(cart: any): Cart {
332
- const currencyCode = cart.currency_code ?? 'usd';
333
-
398
+ const currencyCode = cart.currency_code;
399
+
334
400
  return {
335
401
  id: cart.id,
336
402
  checkoutUrl: `/checkout/${cart.id}`, // Medusa doesn't have a direct checkout URL
337
403
  cost: {
338
- subtotalAmount: transformMoney(cart.subtotal, currencyCode),
339
- totalAmount: transformMoney(cart.total, currencyCode),
340
- totalTaxAmount: transformMoney(cart.tax_total, currencyCode),
404
+ subtotalAmount: transformAmount(
405
+ typeof cart.item_subtotal === 'number' ? cart.item_subtotal : cart.subtotal,
406
+ currencyCode,
407
+ ),
408
+ discountAmount: transformAmount(cart.discount_total, currencyCode),
409
+ totalTaxAmount: transformAmount(cart.tax_total, currencyCode),
410
+ shippingAmount: transformAmount(cart.shipping_total, currencyCode),
411
+ totalAmount: transformAmount(cart.total, currencyCode),
341
412
  },
342
413
  lines: cart.items?.map((item: any) => transformCartItem(item, currencyCode)) ?? [],
343
414
  totalQuantity: cart.items?.reduce((sum: any, item: any) => sum + (item.quantity ?? 0), 0) ?? 0,
415
+ salesChannelId: cart.sales_channel_id ?? undefined,
416
+ regionId: cart.region_id ?? undefined,
417
+ currencyCode:
418
+ typeof currencyCode === 'string' ? currencyCode.toUpperCase() : undefined,
419
+ selectedShippingMethodId: cart.shipping_methods?.[0]?.id ?? undefined,
420
+ selectedShippingOptionId:
421
+ cart.shipping_methods?.[0]?.shipping_option_id ?? undefined,
344
422
  completedAt: cart.completed_at
345
423
  ? (typeof cart.completed_at === 'string'
346
424
  ? cart.completed_at
347
425
  : cart.completed_at?.toISOString())
348
426
  : undefined,
427
+ metadata: cart.metadata ?? undefined,
349
428
  };
350
429
  }
351
430
 
@@ -477,8 +556,8 @@ export function transformOrderItem(item: any, currencyCode: string): OrderItem {
477
556
  title: item.product_title ?? item.title ?? '',
478
557
  variantTitle: item.variant_title ?? variant?.title ?? undefined,
479
558
  quantity: item.quantity ?? 1,
480
- price: transformMoney(item.unit_price, currencyCode),
481
- total: transformMoney(item.total, currencyCode),
559
+ price: transformAmount(item.unit_price, currencyCode),
560
+ total: transformAmount(item.total, currencyCode),
482
561
  sku: variant?.sku ?? undefined,
483
562
  image: product?.thumbnail || item.thumbnail
484
563
  ? transformImage(product?.thumbnail || item.thumbnail, item.product_title ?? '')
@@ -556,12 +635,19 @@ export function transformOrder(order: any): Order {
556
635
  .filter((item: any) => item != null) // Filter out null/undefined items
557
636
  .map((item: any) => transformOrderItem(item, currencyCode)) ?? [],
558
637
 
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),
638
+ // Pricing — backend-authoritative; a legitimate zero is preserved and no
639
+ // amount is derived locally. `subtotal` prefers the backend's
640
+ // `item_subtotal` (lines only, excluding shipping/tax); Medusa's native
641
+ // `subtotal` folds in `shipping_subtotal`, so falling back to it is only
642
+ // for installs that do not supply `item_subtotal`.
643
+ subtotal: transformAmount(
644
+ typeof order.item_subtotal === 'number' ? order.item_subtotal : order.subtotal,
645
+ currencyCode,
646
+ ),
647
+ total: transformAmount(order.total, currencyCode),
648
+ tax: transformAmount(order.tax_total, currencyCode),
649
+ shipping: transformAmount(order.shipping_total, currencyCode),
650
+ discount: transformAmount(order.discount_total, currencyCode),
565
651
 
566
652
  // Addresses (transformed to camelCase)
567
653
  shippingAddress: order.shipping_address