Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cool-swans-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"shopify-buy": patch
---

Fix bug where a shipping discount could appear as if it was a line item discount
5 changes: 5 additions & 0 deletions .changeset/tough-phones-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"shopify-buy": patch
---

Fix bug where adding multiple discount codes to the cart could inadvertently remove some discounts
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ If you migrate to Storefront API Client, there is virtually no use case that can
| shippingLine | ⚠️ | Not supported. Defaults to `null` | Same as above |
| 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. |
| taxesIncluded | ⚠️ | Not supported. Defaults to `false` | Same as above |
| 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)

#### Updated `.checkout` methods

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 33 additions & 12 deletions src/checkout-resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,20 +201,41 @@ class CheckoutResource extends Resource {
* @return {Promise|GraphModel} A promise resolving with the updated checkout.
*/
addDiscount(checkoutId, discountCode) {
return this.fetch(checkoutId).then((checkout) => {
const existingRootCodes = checkout.discountApplications.map(
(discountApplication) => discountApplication.code
);
// We want access to Cart's `discountCodes` field, so we can't just use the
// existing `fetch` method since that also maps and removes the `discountCodes` field.
// We must therefore look at the raw Cart data to be able to see ALL existing discount codes,
// whether they are `applied` or not.

// The query below is identical to the `fetch` method's query EXCEPT we don't call `mapCartPayload` here
return this.graphQLClient.send(cartNodeQuery, {id: checkoutId}).then(({model, data}) => {
return new Promise((resolve, reject) => {
try {
const cart = data.cart || data.node;

if (!cart) {
return resolve(null);
}

const existingLineCodes = checkout.lineItems.map((lineItem) => {
return lineItem.discountAllocations.map(
({discountApplication}) => discountApplication.code
);
});
return this.graphQLClient
.fetchAllPages(model.cart.lines, {pageSize: 250})
.then((lines) => {
model.cart.attrs.lines = lines;

return resolve(model.cart);
});
} catch (error) {
if (error) {
reject(error);
} else {
reject([{message: 'an unknown error has occurred.'}]);
}
}

// get unique applied codes
const existingCodes = Array.from(
new Set([...existingRootCodes, ...existingLineCodes.flat()])
return resolve(null);
});
}).then((checkout) => {
const existingCodes = checkout.discountCodes.map(
(code) => code.code
);

const variables = this.inputMapper.addDiscount(
Expand Down
240 changes: 144 additions & 96 deletions src/utilities/cart-discount-mapping.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,44 +152,84 @@ export function discountMapper({cartLineItems, cartDiscountAllocations, cartDisc
};
}

// For each discount allocation, move the code/title field to be inside the discountApplication.
// This is because the code/title field is part of the discount allocation for a Cart, but part of
// the discount application for a Checkout
//
// CART EXAMPLE: | CHECKOUT EXAMPLE:
// "cart": { | "checkout": {
// "discountAllocations": [ | "discountApplications": {
// { | "nodes": [
// "discountedAmount": { | {
// "amount": "18.0", | "targetSelection": "ALL",
// "currencyCode": "CAD" | "allocationMethod": "EACH",
// }, | "targetType": "SHIPPING_LINE",
// "discountApplication": { | "value": {
// "targetType": "SHIPPING_LINE", | "percentage": 100.0
// "allocationMethod": "EACH", | },
// "targetSelection": "ALL", | "code": "FREESHIPPINGALLCOUNTRIES",
// "value": { | "applicable": true
// "percentage": 100.0 | }
// } | ]
// }, | },
// "code": "FREESHIPPINGALLCOUNTRIES" | }
// } |
// ] |
// "discountCodes": [ |
// { |
// "code": "FREESHIPPINGALLCOUNTRIES", |
// "applicable": true |
// } |
// ], |
// } |
convertToCheckoutDiscountApplicationType(cartLineItems, cartDiscountAllocations);

// While both the Cart and Checkout API return discount allocations for line items and therefore appear similar, they are
// substantially different in how they handle order-level discounts.
//
// The Checkout API ONLY returns discount allocations as a field on line items (for both product-level and order-level discounts).
// Shipping discounts are only returned as part of `checkout.discountApplications` (and do NOT have any discount allocations).
//
// Unlike the Checkout API, the Cart API returns different types of discount allocations in 2 different places:
// 1. Discount allocations as a field on line items (for product-level discounts)
// 2. Discount allocations as a field on the Cart itself (for order-level discounts and shipping discounts)
//
// Therefore, to map the Cart API payload to the equivalent Checkout API payload, we need to go through all of the order-level discount
// allocations on the *Cart*, and determine which line item the discount is allocated to. But first, we must go through the cart-level
// discount allocations to split them into order-level and shipping-level discount allocations.
// - ONLY the order-level discount allocations go onto line items.
const [shippingDiscountAllocations, orderLevelDiscountAllocations] = cartDiscountAllocations.reduce((acc, discountAllocation) => {
if (discountAllocation.discountApplication.targetType === 'SHIPPING_LINE') {
acc[0].push(discountAllocation);
} else {
acc[1].push(discountAllocation);
}

return acc;
}, [[], []]);
const cartLinesWithAllDiscountAllocations =
mergeCartOrderLevelDiscountAllocationsToCartLineDiscountAllocations({
lineItems: cartLineItems,
orderLevelDiscountAllocationsForLines: findLineIdForEachOrderLevelDiscountAllocation(
cartLineItems,
cartDiscountAllocations
orderLevelDiscountAllocations
)
});

// The Cart API and Checkout API have almost identical fields for discount applications, but the `value` field's behaviour (for fixed-amount discounts)
// is different.
//
// 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
// for that discount.
// With the Cart API, the `value` field of a discount application is always equal to the `allocatedAmount` of the discount allocation that the discount
// 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
// discount, and sum up all of the allocated amounts to determine the TOTAL value of the discount.
const discountIdToDiscountApplicationMap = generateDiscountApplications(
cartLinesWithAllDiscountAllocations,
shippingDiscountAllocations,
cartDiscountCodes
);

cartDiscountCodes.forEach(({code, codeIsApplied}) => {
if (!codeIsApplied) { return; }

// Check if the code exists in the map (case-insensitive)
let found = false;

for (const [key] of discountIdToDiscountApplicationMap) {
if (key.toLowerCase() === code.toLowerCase()) {
found = true;
break;
}
}
if (!found) {
throw new Error(
`Discount code ${code} not found in discount application map.
Discount application map: ${JSON.stringify(
discountIdToDiscountApplicationMap
)}`
);
}
});

return {
discountApplications: Array.from(
discountIdToDiscountApplicationMap.values()
Expand Down Expand Up @@ -222,7 +262,7 @@ function mergeCartOrderLevelDiscountAllocationsToCartLineDiscountAllocations({
});
}

function generateDiscountApplications(cartLinesWithAllDiscountAllocations, discountCodes) {
function generateDiscountApplications(cartLinesWithAllDiscountAllocations, shippingDiscountAllocations, discountCodes) {
const discountIdToDiscountApplicationMap = new Map();

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

discountAllocations.forEach((discountAllocation) => {
const discountApp = discountAllocation.discountApplication;
const discountId = getDiscountAllocationId(discountAllocation);
createCheckoutDiscountApplicationFromCartDiscountAllocation(discountAllocation, discountIdToDiscountApplicationMap, discountCodes);
});
});

shippingDiscountAllocations.forEach((discountAllocation) => {
createCheckoutDiscountApplicationFromCartDiscountAllocation(discountAllocation, discountIdToDiscountApplicationMap, discountCodes);
});

return discountIdToDiscountApplicationMap;
}

function createCheckoutDiscountApplicationFromCartDiscountAllocation(discountAllocation, discountIdToDiscountApplicationMap, discountCodes) {
const discountApp = discountAllocation.discountApplication;
const discountId = getDiscountAllocationId(discountAllocation);

if (!discountId) {
if (!discountId) {
throw new Error(
`Discount allocation must have either code or title in discountApplication: ${JSON.stringify(
discountAllocation
)}`
);
}

if (discountIdToDiscountApplicationMap.has(discountId.toLowerCase())) {
const existingDiscountApplication =
discountIdToDiscountApplicationMap.get(discountId.toLowerCase());

// if existingDiscountApplication.value is an amount rather than a percentage discount
if (existingDiscountApplication.value && 'amount' in existingDiscountApplication.value) {
existingDiscountApplication.value = {
amount: (Number(existingDiscountApplication.value.amount) + Number(discountAllocation.discountedAmount.amount)).toFixed(2),
currencyCode: existingDiscountApplication.value.currencyCode,
type: existingDiscountApplication.value.type
};
}
} else {
let discountApplication = {
__typename: 'DiscountApplication',
targetSelection: discountApp.targetSelection,
allocationMethod: discountApp.allocationMethod,
targetType: discountApp.targetType,
value: discountApp.value,
hasNextPage: false,
hasPreviousPage: false
};

if ('code' in discountAllocation.discountApplication) {
const discountCode = discountCodes.find(
({code}) => code.toLowerCase() === discountId.toLowerCase()
);

if (!discountCode) {
throw new Error(
`Discount allocation must have either code or title in discountApplication: ${JSON.stringify(
discountAllocation
`Discount code ${discountId} not found in cart discount codes. Discount codes: ${JSON.stringify(
discountCodes
)}`
);
}

if (discountIdToDiscountApplicationMap.has(discountId.toLowerCase())) {
const existingDiscountApplication =
discountIdToDiscountApplicationMap.get(discountId.toLowerCase());

// if existingDiscountApplication.value is an amount rather than a percentage discount
if (existingDiscountApplication.value && 'amount' in existingDiscountApplication.value) {
existingDiscountApplication.value = {
amount: (Number(existingDiscountApplication.value.amount) + Number(discountAllocation.discountedAmount.amount)).toFixed(2),
currencyCode: existingDiscountApplication.value.currencyCode,
type: existingDiscountApplication.value.type
};
discountApplication = Object.assign({}, discountApplication, {
code: discountAllocation.discountApplication.code,
applicable: discountCode.applicable,
type: {
fieldBaseTypes: {
applicable: 'Boolean',
code: 'String'
},
implementsNode: false,
kind: 'OBJECT',
name: 'DiscountApplication'
}
} else {
let discountApplication = {
__typename: 'DiscountApplication',
targetSelection: discountApp.targetSelection,
allocationMethod: discountApp.allocationMethod,
targetType: discountApp.targetType,
value: discountApp.value,
hasNextPage: false,
hasPreviousPage: false
};

if ('code' in discountAllocation.discountApplication) {
const discountCode = discountCodes.find(
({code}) => code.toLowerCase() === discountId.toLowerCase()
);

if (!discountCode) {
throw new Error(
`Discount code ${discountId} not found in cart discount codes. Discount codes: ${JSON.stringify(
discountCodes
)}`
);
}
discountApplication = Object.assign({}, discountApplication, {
code: discountAllocation.discountApplication.code,
applicable: discountCode.applicable,
type: {
fieldBaseTypes: {
applicable: 'Boolean',
code: 'String'
},
implementsNode: false,
kind: 'OBJECT',
name: 'DiscountApplication'
}
});
} else {
discountApplication = Object.assign({}, discountApplication, {
title: discountAllocation.discountApplication.title,
type: {
fieldBaseTypes: {
applicable: 'Boolean',
title: 'String'
},
implementsNode: false,
kind: 'OBJECT',
name: 'DiscountApplication'
}
});
});
} else {
discountApplication = Object.assign({}, discountApplication, {
title: discountAllocation.discountApplication.title,
type: {
fieldBaseTypes: {
applicable: 'Boolean',
title: 'String'
},
implementsNode: false,
kind: 'OBJECT',
name: 'DiscountApplication'
}
});
}

discountIdToDiscountApplicationMap.set(discountId.toLowerCase(), discountApplication);
}
});
});

return discountIdToDiscountApplicationMap;
discountIdToDiscountApplicationMap.set(discountId.toLowerCase(), discountApplication);
}
}

export function deepSortLines(lineItems) {
Expand Down
Loading