Skip to content

Commit 11d58f7

Browse files
fix(cart): fetch totals with all fields + fix inconsistent total calculation (#16022)
Note: this should be merged after #16021 to use the shared `normalizeBigNumbers` utility ## Fix 1: Return totals when * fields are selected Previously, we only returned totals when they're explicitely selected as the computation is expensive. However, this leads to unexpected results when a user passes `*` in the fields. So the following wouldn't work as expected: ```ts // doesn't return totals const { data: carts } = await query.graph({ entity: "cart", fields: ["*"] }) // also doesn't return total. We strip all fields at the query layer when * is present const { data: carts } = await query.graph({ entity: "cart", fields: ["*", "total"] }) // this works const { data: carts } = await query.graph({ entity: "cart", fields: ["total", "id"] }) ``` With the change in this PR, the totals will be returned as expected with `*`. This is fair as `*` by generally expensive and discouraged in the docs, and it matches the expectations of a user using it. ### Caveat All retrievals of `*` fields or not passing `select` to `listOrders` (and similar methods) will result in totals retrieval, since those will retrieve all order fields. Need to be documented and included as part of the next release notes. To avoid retrieving them, users must pass either an empty array (retrieving no fields), or specify specific fields to retrieve. ## Fix 2: Total miscalculations based on retrieved items In some use cases when a retrieving a specific field of cart's items without retrieving the quantity, the calculated totals are miscalculated. For example: ```ts // totals are calculated const { data: carts } = await query.graph({ entity: "cart", fields: ["total", "items.quantity"] }) // totals are miscalculated as there's no quantity on the item const { data: carts } = await query.graph({ entity: "cart", fields: ["total", "items.unit_price"] }) ``` This is unexpected behavior, the total shouldn't be impacted by which fields the user selects. The solution in this PR has a caveat, which is that the quantity of items is now always returned when totals are returned even if not explicitely selected. Co-authored-by: Nicolas Gorga <62995075+NicolasGorga@users.noreply.github.com>
1 parent 4f2b8a2 commit 11d58f7

5 files changed

Lines changed: 201 additions & 33 deletions

File tree

.changeset/humble-icons-invent.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@medusajs/cart": patch
3+
---
4+
5+
fix(cart): fetch totals with all fields + fix inconsistent total calculation
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
2+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
3+
4+
jest.setTimeout(120000)
5+
6+
// query.graph returns totals as BigNumber instances; coerce for numeric assertions.
7+
const num = (value) => Number(value)
8+
9+
medusaIntegrationTestRunner({
10+
testSuite: ({ getContainer }) => {
11+
describe("Cart totals via Query (query.graph)", () => {
12+
let query
13+
let cartModule
14+
let cartId
15+
16+
// 2 x $10 item + $10 shipping, no taxes/discounts =>
17+
// total 30, subtotal 30, item_subtotal 20, item[0].quantity 2, item[0].total 20
18+
const seedCart = async () => {
19+
const cart = await cartModule.createCarts({
20+
currency_code: "usd",
21+
email: "test@medusajs.com",
22+
items: [
23+
{
24+
title: "Test item",
25+
product_title: "Cool Product",
26+
thumbnail: "https://example.com/thumb.png",
27+
quantity: 2,
28+
unit_price: 10,
29+
},
30+
],
31+
})
32+
await cartModule.addShippingMethods([
33+
{ cart_id: cart.id, name: "Standard", amount: 10 },
34+
])
35+
return cart.id
36+
}
37+
38+
beforeAll(() => {
39+
const container = getContainer()
40+
query = container.resolve(ContainerRegistrationKeys.QUERY)
41+
cartModule = container.resolve(Modules.CART)
42+
})
43+
44+
beforeEach(async () => {
45+
cartId = await seedCart()
46+
})
47+
48+
afterEach(async () => {
49+
await cartModule.deleteCarts([cartId])
50+
})
51+
52+
it("returns cart totals when explicit total fields are requested", async () => {
53+
const {
54+
data: [cart],
55+
} = await query.graph({
56+
entity: "cart",
57+
fields: [
58+
"id",
59+
"currency_code",
60+
"total",
61+
"subtotal",
62+
"tax_total",
63+
"shipping_total",
64+
"item_subtotal",
65+
"items.*",
66+
],
67+
filters: { id: cartId },
68+
})
69+
70+
expect(num(cart.total)).toEqual(30)
71+
expect(num(cart.subtotal)).toEqual(30)
72+
expect(num(cart.tax_total)).toEqual(0)
73+
expect(num(cart.shipping_total)).toEqual(10)
74+
expect(num(cart.item_subtotal)).toEqual(20)
75+
76+
expect(num(cart.items[0].quantity)).toEqual(2)
77+
expect(num(cart.items[0].unit_price)).toEqual(10)
78+
expect(num(cart.items[0].total)).toEqual(20)
79+
expect(num(cart.items[0].subtotal)).toEqual(20)
80+
})
81+
82+
it('returns cart totals when fields is a bare wildcard ("*")', async () => {
83+
const {
84+
data: [cart],
85+
} = await query.graph({
86+
entity: "cart",
87+
fields: ["*"],
88+
filters: { id: cartId },
89+
})
90+
91+
// A bare "*" must NOT suppress the computed totals.
92+
expect(num(cart.total)).toEqual(30)
93+
expect(num(cart.subtotal)).toEqual(30)
94+
expect(num(cart.item_subtotal)).toEqual(20)
95+
})
96+
97+
it('returns cart totals when "*" is combined with explicit total fields', async () => {
98+
const {
99+
data: [cart],
100+
} = await query.graph({
101+
entity: "cart",
102+
fields: ["*", "total", "subtotal"],
103+
filters: { id: cartId },
104+
})
105+
106+
expect(num(cart.total)).toEqual(30)
107+
expect(num(cart.subtotal)).toEqual(30)
108+
})
109+
110+
it("computes correct cart total when scoped item totals are requested alongside a total", async () => {
111+
// Regression: requesting computed item totals (`items.total`,
112+
// `items.subtotal`) next to a scoped scalar used to under-count the cart
113+
// total to shipping-only (10) with item totals of 0.
114+
const {
115+
data: [cart],
116+
} = await query.graph({
117+
entity: "cart",
118+
fields: [
119+
"total",
120+
"item_subtotal",
121+
"items.total",
122+
"items.subtotal",
123+
"items.product_title",
124+
],
125+
filters: { id: cartId },
126+
})
127+
128+
expect(num(cart.total)).toEqual(30)
129+
expect(num(cart.item_subtotal)).toEqual(20)
130+
expect(num(cart.items[0].total)).toEqual(20)
131+
expect(num(cart.items[0].subtotal)).toEqual(20)
132+
expect(cart.items[0].product_title).toEqual("Cool Product")
133+
})
134+
135+
it("computes correct totals with a partial scoped item selection", async () => {
136+
// `items.quantity` without `items.unit_price` previously starved the
137+
// calc, zeroing the item total and under-counting the cart total.
138+
const {
139+
data: [cart],
140+
} = await query.graph({
141+
entity: "cart",
142+
fields: ["total", "item_subtotal", "items.quantity", "items.total"],
143+
filters: { id: cartId },
144+
})
145+
146+
expect(num(cart.total)).toEqual(30)
147+
expect(num(cart.item_subtotal)).toEqual(20)
148+
expect(num(cart.items[0].quantity)).toEqual(2)
149+
expect(num(cart.items[0].total)).toEqual(20)
150+
})
151+
})
152+
},
153+
})

0 commit comments

Comments
 (0)