Skip to content

Commit 1edd3d2

Browse files
authored
Merge pull request #1039 from Shopify/kd-shipping-discount-fix
2 discount bug fixes (multiple discounts + shipping discounts)
2 parents 662d9d6 + 245726e commit 1edd3d2

7 files changed

Lines changed: 1627 additions & 190 deletions

File tree

.changeset/cool-swans-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"shopify-buy": patch
3+
---
4+
5+
Fix bug where a shipping discount could appear as if it was a line item discount

.changeset/tough-phones-sip.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"shopify-buy": patch
3+
---
4+
5+
Fix bug where adding multiple discount codes to the cart could inadvertently remove some discounts

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ If you migrate to Storefront API Client, there is virtually no use case that can
7070
| shippingLine | ⚠️ | Not supported. Defaults to `null` | Same as above |
7171
| taxExempt | ⚠️ | Not supported. Defaults to `false` | The [Cart API](https://shopify.dev/docs/api/storefront/2025-01/objects/cart) is not tax aware, as taxes are currently handled in the Checkout flow. Remove any existing code depending on this field. |
7272
| taxesIncluded | ⚠️ | Not supported. Defaults to `false` | Same as above |
73+
| discountApplications | ✅⚠️ | If a buyer's shipping address is unknown and a shipping discount is applied, shipping discount information is **no longer** returned | In this situation, the [Cart API](https://shopify.dev/docs/api/storefront/2025-01/objects/cart) does not return any information about the value of the shipping discount (eg: whether it's a 100% discount or a $5 off discount)
7374

7475
#### Updated `.checkout` methods
7576

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/checkout-resource.js

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -201,20 +201,41 @@ class CheckoutResource extends Resource {
201201
* @return {Promise|GraphModel} A promise resolving with the updated checkout.
202202
*/
203203
addDiscount(checkoutId, discountCode) {
204-
return this.fetch(checkoutId).then((checkout) => {
205-
const existingRootCodes = checkout.discountApplications.map(
206-
(discountApplication) => discountApplication.code
207-
);
204+
// We want access to Cart's `discountCodes` field, so we can't just use the
205+
// existing `fetch` method since that also maps and removes the `discountCodes` field.
206+
// We must therefore look at the raw Cart data to be able to see ALL existing discount codes,
207+
// whether they are `applied` or not.
208+
209+
// The query below is identical to the `fetch` method's query EXCEPT we don't call `mapCartPayload` here
210+
return this.graphQLClient.send(cartNodeQuery, {id: checkoutId}).then(({model, data}) => {
211+
return new Promise((resolve, reject) => {
212+
try {
213+
const cart = data.cart || data.node;
214+
215+
if (!cart) {
216+
return resolve(null);
217+
}
208218

209-
const existingLineCodes = checkout.lineItems.map((lineItem) => {
210-
return lineItem.discountAllocations.map(
211-
({discountApplication}) => discountApplication.code
212-
);
213-
});
219+
return this.graphQLClient
220+
.fetchAllPages(model.cart.lines, {pageSize: 250})
221+
.then((lines) => {
222+
model.cart.attrs.lines = lines;
223+
224+
return resolve(model.cart);
225+
});
226+
} catch (error) {
227+
if (error) {
228+
reject(error);
229+
} else {
230+
reject([{message: 'an unknown error has occurred.'}]);
231+
}
232+
}
214233

215-
// get unique applied codes
216-
const existingCodes = Array.from(
217-
new Set([...existingRootCodes, ...existingLineCodes.flat()])
234+
return resolve(null);
235+
});
236+
}).then((checkout) => {
237+
const existingCodes = checkout.discountCodes.map(
238+
(code) => code.code
218239
);
219240

220241
const variables = this.inputMapper.addDiscount(

src/utilities/cart-discount-mapping.js

Lines changed: 144 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -152,44 +152,84 @@ export function discountMapper({cartLineItems, cartDiscountAllocations, cartDisc
152152
};
153153
}
154154

155+
// For each discount allocation, move the code/title field to be inside the discountApplication.
156+
// This is because the code/title field is part of the discount allocation for a Cart, but part of
157+
// the discount application for a Checkout
158+
//
159+
// CART EXAMPLE: | CHECKOUT EXAMPLE:
160+
// "cart": { | "checkout": {
161+
// "discountAllocations": [ | "discountApplications": {
162+
// { | "nodes": [
163+
// "discountedAmount": { | {
164+
// "amount": "18.0", | "targetSelection": "ALL",
165+
// "currencyCode": "CAD" | "allocationMethod": "EACH",
166+
// }, | "targetType": "SHIPPING_LINE",
167+
// "discountApplication": { | "value": {
168+
// "targetType": "SHIPPING_LINE", | "percentage": 100.0
169+
// "allocationMethod": "EACH", | },
170+
// "targetSelection": "ALL", | "code": "FREESHIPPINGALLCOUNTRIES",
171+
// "value": { | "applicable": true
172+
// "percentage": 100.0 | }
173+
// } | ]
174+
// }, | },
175+
// "code": "FREESHIPPINGALLCOUNTRIES" | }
176+
// } |
177+
// ] |
178+
// "discountCodes": [ |
179+
// { |
180+
// "code": "FREESHIPPINGALLCOUNTRIES", |
181+
// "applicable": true |
182+
// } |
183+
// ], |
184+
// } |
155185
convertToCheckoutDiscountApplicationType(cartLineItems, cartDiscountAllocations);
156186

187+
// While both the Cart and Checkout API return discount allocations for line items and therefore appear similar, they are
188+
// substantially different in how they handle order-level discounts.
189+
//
190+
// The Checkout API ONLY returns discount allocations as a field on line items (for both product-level and order-level discounts).
191+
// Shipping discounts are only returned as part of `checkout.discountApplications` (and do NOT have any discount allocations).
192+
//
193+
// Unlike the Checkout API, the Cart API returns different types of discount allocations in 2 different places:
194+
// 1. Discount allocations as a field on line items (for product-level discounts)
195+
// 2. Discount allocations as a field on the Cart itself (for order-level discounts and shipping discounts)
196+
//
197+
// Therefore, to map the Cart API payload to the equivalent Checkout API payload, we need to go through all of the order-level discount
198+
// allocations on the *Cart*, and determine which line item the discount is allocated to. But first, we must go through the cart-level
199+
// discount allocations to split them into order-level and shipping-level discount allocations.
200+
// - ONLY the order-level discount allocations go onto line items.
201+
const [shippingDiscountAllocations, orderLevelDiscountAllocations] = cartDiscountAllocations.reduce((acc, discountAllocation) => {
202+
if (discountAllocation.discountApplication.targetType === 'SHIPPING_LINE') {
203+
acc[0].push(discountAllocation);
204+
} else {
205+
acc[1].push(discountAllocation);
206+
}
207+
208+
return acc;
209+
}, [[], []]);
157210
const cartLinesWithAllDiscountAllocations =
158211
mergeCartOrderLevelDiscountAllocationsToCartLineDiscountAllocations({
159212
lineItems: cartLineItems,
160213
orderLevelDiscountAllocationsForLines: findLineIdForEachOrderLevelDiscountAllocation(
161214
cartLineItems,
162-
cartDiscountAllocations
215+
orderLevelDiscountAllocations
163216
)
164217
});
165218

219+
// The Cart API and Checkout API have almost identical fields for discount applications, but the `value` field's behaviour (for fixed-amount discounts)
220+
// is different.
221+
//
222+
// With the Checkout API, the `value` field of a discount application is equal to the SUM of all of the `allocatedAmount`s of all of the discount allocations
223+
// for that discount.
224+
// With the Cart API, the `value` field of a discount application is always equal to the `allocatedAmount` of the discount allocation that the discount
225+
// application is inside of. Therefore, to map this to the equivalent Checkout API payload, we need to find all of the discount allocations for the same
226+
// discount, and sum up all of the allocated amounts to determine the TOTAL value of the discount.
166227
const discountIdToDiscountApplicationMap = generateDiscountApplications(
167228
cartLinesWithAllDiscountAllocations,
229+
shippingDiscountAllocations,
168230
cartDiscountCodes
169231
);
170232

171-
cartDiscountCodes.forEach(({code, codeIsApplied}) => {
172-
if (!codeIsApplied) { return; }
173-
174-
// Check if the code exists in the map (case-insensitive)
175-
let found = false;
176-
177-
for (const [key] of discountIdToDiscountApplicationMap) {
178-
if (key.toLowerCase() === code.toLowerCase()) {
179-
found = true;
180-
break;
181-
}
182-
}
183-
if (!found) {
184-
throw new Error(
185-
`Discount code ${code} not found in discount application map.
186-
Discount application map: ${JSON.stringify(
187-
discountIdToDiscountApplicationMap
188-
)}`
189-
);
190-
}
191-
});
192-
193233
return {
194234
discountApplications: Array.from(
195235
discountIdToDiscountApplicationMap.values()
@@ -222,7 +262,7 @@ function mergeCartOrderLevelDiscountAllocationsToCartLineDiscountAllocations({
222262
});
223263
}
224264

225-
function generateDiscountApplications(cartLinesWithAllDiscountAllocations, discountCodes) {
265+
function generateDiscountApplications(cartLinesWithAllDiscountAllocations, shippingDiscountAllocations, discountCodes) {
226266
const discountIdToDiscountApplicationMap = new Map();
227267

228268
if (!cartLinesWithAllDiscountAllocations) { return discountIdToDiscountApplicationMap; }
@@ -231,86 +271,94 @@ function generateDiscountApplications(cartLinesWithAllDiscountAllocations, disco
231271
if (!discountAllocations) { return; }
232272

233273
discountAllocations.forEach((discountAllocation) => {
234-
const discountApp = discountAllocation.discountApplication;
235-
const discountId = getDiscountAllocationId(discountAllocation);
274+
createCheckoutDiscountApplicationFromCartDiscountAllocation(discountAllocation, discountIdToDiscountApplicationMap, discountCodes);
275+
});
276+
});
277+
278+
shippingDiscountAllocations.forEach((discountAllocation) => {
279+
createCheckoutDiscountApplicationFromCartDiscountAllocation(discountAllocation, discountIdToDiscountApplicationMap, discountCodes);
280+
});
281+
282+
return discountIdToDiscountApplicationMap;
283+
}
284+
285+
function createCheckoutDiscountApplicationFromCartDiscountAllocation(discountAllocation, discountIdToDiscountApplicationMap, discountCodes) {
286+
const discountApp = discountAllocation.discountApplication;
287+
const discountId = getDiscountAllocationId(discountAllocation);
236288

237-
if (!discountId) {
289+
if (!discountId) {
290+
throw new Error(
291+
`Discount allocation must have either code or title in discountApplication: ${JSON.stringify(
292+
discountAllocation
293+
)}`
294+
);
295+
}
296+
297+
if (discountIdToDiscountApplicationMap.has(discountId.toLowerCase())) {
298+
const existingDiscountApplication =
299+
discountIdToDiscountApplicationMap.get(discountId.toLowerCase());
300+
301+
// if existingDiscountApplication.value is an amount rather than a percentage discount
302+
if (existingDiscountApplication.value && 'amount' in existingDiscountApplication.value) {
303+
existingDiscountApplication.value = {
304+
amount: (Number(existingDiscountApplication.value.amount) + Number(discountAllocation.discountedAmount.amount)).toFixed(2),
305+
currencyCode: existingDiscountApplication.value.currencyCode,
306+
type: existingDiscountApplication.value.type
307+
};
308+
}
309+
} else {
310+
let discountApplication = {
311+
__typename: 'DiscountApplication',
312+
targetSelection: discountApp.targetSelection,
313+
allocationMethod: discountApp.allocationMethod,
314+
targetType: discountApp.targetType,
315+
value: discountApp.value,
316+
hasNextPage: false,
317+
hasPreviousPage: false
318+
};
319+
320+
if ('code' in discountAllocation.discountApplication) {
321+
const discountCode = discountCodes.find(
322+
({code}) => code.toLowerCase() === discountId.toLowerCase()
323+
);
324+
325+
if (!discountCode) {
238326
throw new Error(
239-
`Discount allocation must have either code or title in discountApplication: ${JSON.stringify(
240-
discountAllocation
327+
`Discount code ${discountId} not found in cart discount codes. Discount codes: ${JSON.stringify(
328+
discountCodes
241329
)}`
242330
);
243331
}
244-
245-
if (discountIdToDiscountApplicationMap.has(discountId.toLowerCase())) {
246-
const existingDiscountApplication =
247-
discountIdToDiscountApplicationMap.get(discountId.toLowerCase());
248-
249-
// if existingDiscountApplication.value is an amount rather than a percentage discount
250-
if (existingDiscountApplication.value && 'amount' in existingDiscountApplication.value) {
251-
existingDiscountApplication.value = {
252-
amount: (Number(existingDiscountApplication.value.amount) + Number(discountAllocation.discountedAmount.amount)).toFixed(2),
253-
currencyCode: existingDiscountApplication.value.currencyCode,
254-
type: existingDiscountApplication.value.type
255-
};
332+
discountApplication = Object.assign({}, discountApplication, {
333+
code: discountAllocation.discountApplication.code,
334+
applicable: discountCode.applicable,
335+
type: {
336+
fieldBaseTypes: {
337+
applicable: 'Boolean',
338+
code: 'String'
339+
},
340+
implementsNode: false,
341+
kind: 'OBJECT',
342+
name: 'DiscountApplication'
256343
}
257-
} else {
258-
let discountApplication = {
259-
__typename: 'DiscountApplication',
260-
targetSelection: discountApp.targetSelection,
261-
allocationMethod: discountApp.allocationMethod,
262-
targetType: discountApp.targetType,
263-
value: discountApp.value,
264-
hasNextPage: false,
265-
hasPreviousPage: false
266-
};
267-
268-
if ('code' in discountAllocation.discountApplication) {
269-
const discountCode = discountCodes.find(
270-
({code}) => code.toLowerCase() === discountId.toLowerCase()
271-
);
272-
273-
if (!discountCode) {
274-
throw new Error(
275-
`Discount code ${discountId} not found in cart discount codes. Discount codes: ${JSON.stringify(
276-
discountCodes
277-
)}`
278-
);
279-
}
280-
discountApplication = Object.assign({}, discountApplication, {
281-
code: discountAllocation.discountApplication.code,
282-
applicable: discountCode.applicable,
283-
type: {
284-
fieldBaseTypes: {
285-
applicable: 'Boolean',
286-
code: 'String'
287-
},
288-
implementsNode: false,
289-
kind: 'OBJECT',
290-
name: 'DiscountApplication'
291-
}
292-
});
293-
} else {
294-
discountApplication = Object.assign({}, discountApplication, {
295-
title: discountAllocation.discountApplication.title,
296-
type: {
297-
fieldBaseTypes: {
298-
applicable: 'Boolean',
299-
title: 'String'
300-
},
301-
implementsNode: false,
302-
kind: 'OBJECT',
303-
name: 'DiscountApplication'
304-
}
305-
});
344+
});
345+
} else {
346+
discountApplication = Object.assign({}, discountApplication, {
347+
title: discountAllocation.discountApplication.title,
348+
type: {
349+
fieldBaseTypes: {
350+
applicable: 'Boolean',
351+
title: 'String'
352+
},
353+
implementsNode: false,
354+
kind: 'OBJECT',
355+
name: 'DiscountApplication'
306356
}
357+
});
358+
}
307359

308-
discountIdToDiscountApplicationMap.set(discountId.toLowerCase(), discountApplication);
309-
}
310-
});
311-
});
312-
313-
return discountIdToDiscountApplicationMap;
360+
discountIdToDiscountApplicationMap.set(discountId.toLowerCase(), discountApplication);
361+
}
314362
}
315363

316364
export function deepSortLines(lineItems) {

0 commit comments

Comments
 (0)