diff --git a/integration-tests/api/__tests__/store/cart.js b/integration-tests/api/__tests__/store/cart.js index e771bdc610..6973014f02 100644 --- a/integration-tests/api/__tests__/store/cart.js +++ b/integration-tests/api/__tests__/store/cart.js @@ -14,6 +14,19 @@ describe("/store/carts", () => { let medusaProcess; let dbConnection; + const doAfterEach = async (manager) => { + await manager.query(`DELETE FROM "discount"`); + await manager.query(`DELETE FROM "discount_rule"`); + await manager.query(`DELETE FROM "shipping_method"`); + await manager.query(`DELETE FROM "shipping_option"`); + await manager.query(`DELETE FROM "cart"`); + await manager.query(`DELETE FROM "customer"`); + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` + ); + await manager.query(`DELETE FROM "region"`); + }; + beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..")); dbConnection = await initDb({ cwd }); @@ -43,13 +56,7 @@ describe("/store/carts", () => { afterEach(async () => { const manager = dbConnection.manager; - await manager.query(`DELETE FROM "cart"`); - await manager.query(`DELETE FROM "discount"`); - await manager.query(`DELETE FROM "discount_rule"`); - await manager.query( - `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` - ); - await manager.query(`DELETE FROM "region"`); + await doAfterEach(manager); }); it("creates a cart", async () => { @@ -108,14 +115,7 @@ describe("/store/carts", () => { afterEach(async () => { const manager = dbConnection.manager; - await manager.query(`DELETE FROM "cart"`); - await manager.query(`DELETE FROM "discount"`); - await manager.query(`DELETE FROM "discount_rule"`); - await manager.query(`DELETE FROM "customer"`); - await manager.query( - `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` - ); - await manager.query(`DELETE FROM "region"`); + await doAfterEach(manager); }); it("fails on apply discount if limit has been reached", async () => { @@ -142,6 +142,73 @@ describe("/store/carts", () => { expect(response.status).toEqual(200); }); + + it("adds free shipping to cart then removes it again", async () => { + const api = useApi(); + + let cart = await api.post( + "/store/carts/test-cart", + { + discounts: [{ code: "FREE_SHIPPING" }, { code: "CREATED" }], + }, + { withCredentials: true } + ); + + expect(cart.data.cart.shipping_total).toBe(0); + expect(cart.status).toEqual(200); + + cart = await api.post( + "/store/carts/test-cart", + { + discounts: [{ code: "CREATED" }], + }, + { withCredentials: true } + ); + + expect(cart.data.cart.shipping_total).toBe(1000); + expect(cart.status).toEqual(200); + }); + }); + + describe("DELETE /store/carts/:id/discounts/:code", () => { + beforeEach(async () => { + try { + await cartSeeder(dbConnection); + await dbConnection.manager.query( + `INSERT INTO "cart_discounts" (cart_id, discount_id) VALUES ('test-cart', 'free-shipping')` + ); + } catch (err) { + console.log(err); + throw err; + } + }); + + afterEach(async () => { + const manager = dbConnection.manager; + await doAfterEach(manager); + }); + + it("removes free shipping and updates shipping total", async () => { + const api = useApi(); + + const cartWithFreeShipping = await api.post( + "/store/carts/test-cart", + { + discounts: [{ code: "FREE_SHIPPING" }], + }, + { withCredentials: true } + ); + + expect(cartWithFreeShipping.data.cart.shipping_total).toBe(0); + expect(cartWithFreeShipping.status).toEqual(200); + + const response = await api.delete( + "/store/carts/test-cart/discounts/FREE_SHIPPING" + ); + + expect(response.data.cart.shipping_total).toBe(1000); + expect(response.status).toEqual(200); + }); }); describe("get-cart with session customer", () => { @@ -156,14 +223,7 @@ describe("/store/carts", () => { afterEach(async () => { const manager = dbConnection.manager; - await manager.query(`DELETE FROM "cart"`); - await manager.query(`DELETE FROM "discount"`); - await manager.query(`DELETE FROM "discount_rule"`); - await manager.query(`DELETE FROM "customer"`); - await manager.query( - `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` - ); - await manager.query(`DELETE FROM "region"`); + await doAfterEach(manager); }); it("updates empty cart.customer_id on cart retrieval", async () => { diff --git a/integration-tests/api/helpers/cart-seeder.js b/integration-tests/api/helpers/cart-seeder.js index 73f512dbf1..7280e6d9bf 100644 --- a/integration-tests/api/helpers/cart-seeder.js +++ b/integration-tests/api/helpers/cart-seeder.js @@ -4,11 +4,18 @@ const { Cart, DiscountRule, Discount, + ShippingProfile, + ShippingOption, + ShippingMethod, } = require("@medusajs/medusa"); module.exports = async (connection, data = {}) => { const manager = connection.manager; + const defaultProfile = await manager.findOne(ShippingProfile, { + type: "default", + }); + const r = manager.create(Region, { id: "test-region", name: "Test Region", @@ -16,24 +23,26 @@ module.exports = async (connection, data = {}) => { tax_rate: 0, }); - await manager.insert(DiscountRule, { - id: "test-discount-rule", - description: "Dynamic rule", - type: "percentage", - value: 10, + const freeRule = manager.create(DiscountRule, { + id: "free-shipping-rule", + description: "Free shipping rule", + type: "free_shipping", + value: 100, allocation: "total", }); - await manager.insert(Discount, { - id: "test-discount", - code: "DYNAMIC", - rule_id: "test-discount-rule", - is_dynamic: true, - usage_count: 0, - usage_limit: 1, + const freeDisc = manager.create(Discount, { + id: "free-shipping", + code: "FREE_SHIPPING", + is_dynamic: false, is_disabled: false, }); + freeDisc.regions = [r]; + freeDisc.rule = freeRule; + + await manager.save(freeDisc); + const d = await manager.create(Discount, { id: "test-discount", code: "CREATED", @@ -73,6 +82,17 @@ module.exports = async (connection, data = {}) => { email: "some-customer@email.com", }); + await manager.insert(ShippingOption, { + id: "test-option", + name: "test-option", + provider_id: "test-ful", + region_id: "test-region", + profile_id: defaultProfile.id, + price_type: "flat_rate", + amount: 1000, + data: {}, + }); + const cart = manager.create(Cart, { id: "test-cart", customer_id: "some-customer", @@ -88,4 +108,12 @@ module.exports = async (connection, data = {}) => { }); await manager.save(cart); + + await manager.insert(ShippingMethod, { + id: "test-method", + shipping_option_id: "test-option", + cart_id: "test-cart", + price: 1000, + data: {}, + }); }; diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/update-cart.js b/packages/medusa/src/api/routes/store/carts/__tests__/update-cart.js index b4020be2d7..c840b37788 100644 --- a/packages/medusa/src/api/routes/store/carts/__tests__/update-cart.js +++ b/packages/medusa/src/api/routes/store/carts/__tests__/update-cart.js @@ -65,7 +65,7 @@ describe("POST /store/carts/:id", () => { expect(CartServiceMock.retrieve).toHaveBeenCalledWith( IdMap.getId("emptyCart"), { - relations: ["payment_sessions"], + relations: ["payment_sessions", "shipping_methods"], } ) expect(CartServiceMock.retrieve).toHaveBeenCalledWith( diff --git a/packages/medusa/src/api/routes/store/carts/update-cart.js b/packages/medusa/src/api/routes/store/carts/update-cart.js index d1b9e358a4..dbd871c676 100644 --- a/packages/medusa/src/api/routes/store/carts/update-cart.js +++ b/packages/medusa/src/api/routes/store/carts/update-cart.js @@ -104,7 +104,7 @@ export default async (req, res) => { // If the cart has payment sessions update these const updated = await cartService.retrieve(id, { - relations: ["payment_sessions"], + relations: ["payment_sessions", "shipping_methods"], }) if (updated.payment_sessions?.length && !value.region_id) { diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 11a9c72a69..5a29db6ca6 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -1281,6 +1281,7 @@ describe("CartService", () => { profile_id: IdMap.getId(m.profile), }, })), + discounts: [], } } diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index 72995a5931..fcdbd8ca86 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -1,7 +1,6 @@ import _ from "lodash" import { Validator, MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" -import { defaultFields, defaultRelations } from "../api/routes/store/carts" /** * Provides layer to manipulate carts. @@ -574,6 +573,49 @@ class CartService extends BaseService { }) } + /** + * Ensures shipping total on cart is correct in regards to a potential free + * shipping discount + * If a free shipping is present, we set shipping methods price to 0 + * if a free shipping was present, we set shipping methods to original amount + * @param {Cart} cart - the the cart to adjust free shipping for + * @param {boolean} shouldAdd - flag to indicate, if we should add or remove + */ + async adjustFreeShipping_(cart, shouldAdd) { + if (cart.shipping_methods?.length) { + // if any free shipping discounts, we ensure to update shipping method amount + if (shouldAdd) { + await Promise.all( + cart.shipping_methods.map(async sm => { + const smRepo = this.manager_.getCustomRepository( + this.shippingMethodRepository_ + ) + + const method = await smRepo.findOne({ where: { id: sm.id } }) + + if (method) { + method.price = 0 + await smRepo.save(method) + } + }) + ) + } else { + await Promise.all( + cart.shipping_methods.map(async sm => { + const smRepo = this.manager_.getCustomRepository( + this.shippingMethodRepository_ + ) + + // if free shipping discount is removed, we adjust the shipping + // back to its original amount + sm.price = sm.shipping_option.amount + await smRepo.save(sm) + }) + ) + } + } + } + async update(cartId, update) { return this.atomicPhase_(async manager => { const cartRepo = manager.getCustomRepository(this.cartRepository_) @@ -627,10 +669,29 @@ class CartService extends BaseService { } if ("discounts" in update) { + const previousDiscounts = cart.discounts cart.discounts = [] + for (const { code } of update.discounts) { await this.applyDiscount_(cart, code) } + + const hasFreeShipping = cart.discounts.some( + ({ rule }) => rule.type === "free_shipping" + ) + + // if we previously had a free shipping discount and then removed it, + // we need to update shipping methods to original price + if ( + previousDiscounts.some(({ rule }) => rule.type === "free_shipping") && + !hasFreeShipping + ) { + await this.adjustFreeShipping_(cart, false) + } + + if (hasFreeShipping) { + await this.adjustFreeShipping_(cart, true) + } } if ("gift_cards" in update) { @@ -890,8 +951,13 @@ class CartService extends BaseService { async removeDiscount(cartId, discountCode) { return this.atomicPhase_(async manager => { const cart = await this.retrieve(cartId, { - relations: ["discounts", "payment_sessions"], + relations: ["discounts", "payment_sessions", "shipping_methods"], }) + + if (cart.discounts.some(({ rule }) => rule.type === "free_shipping")) { + await this.adjustFreeShipping_(cart, false) + } + cart.discounts = cart.discounts.filter(d => d.code !== discountCode) const cartRepo = manager.getCustomRepository(this.cartRepository_) @@ -1232,6 +1298,7 @@ class CartService extends BaseService { select: ["subtotal"], relations: [ "shipping_methods", + "discounts", "shipping_methods.shipping_option", "items", "items.variant", @@ -1267,7 +1334,15 @@ class CartService extends BaseService { }) } - const result = await this.retrieve(cartId) + const result = await this.retrieve(cartId, { + relations: ["discounts", "shipping_methods"], + }) + + // if cart has freeshipping, adjust price + if (result.discounts.some(({ rule }) => rule.type === "free_shipping")) { + await this.adjustFreeShipping_(result, true) + } + await this.eventBus_ .withTransaction(manager) .emit(CartService.Events.UPDATED, result) diff --git a/packages/medusa/src/services/swap.js b/packages/medusa/src/services/swap.js index 8cc659574d..fef1599ceb 100644 --- a/packages/medusa/src/services/swap.js +++ b/packages/medusa/src/services/swap.js @@ -400,8 +400,13 @@ class SwapService extends BaseService { const order = swap.order + // filter out free shipping discounts + const discounts = + order?.discounts?.filter(({ rule }) => rule.type !== "free_shipping") || + undefined + const cart = await this.cartService_.withTransaction(manager).create({ - discounts: order.discounts, + discounts, email: order.email, billing_address_id: order.billing_address_id, shipping_address_id: order.shipping_address_id,