From 51aaf5105c921ec0058817d2cf5b103483bc59e7 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 16 Mar 2020 09:48:52 +0100 Subject: [PATCH] Completes Cart Service and store/carts endpoints (#18) Completes Cart Service to allow shopping and checkout flows. --- docs/api/store/carts.md | 9 + docs/services/CartService.md | 88 ++- .../src/api/middlewares/error-handler.js | 1 + .../carts/__tests__/add-shipping-method.js | 166 +++++ .../store/carts/__tests__/create-cart.js | 10 +- .../store/carts/__tests__/create-line-item.js | 87 +++ .../__tests__/create-payment-sessions.js | 33 + .../__tests__/create-shipping-options.js | 33 + .../routes/store/carts/__tests__/get-cart.js | 1 + .../store/carts/__tests__/update-cart.js | 1 + .../store/carts/__tests__/update-line-item.js | 92 +++ .../carts/__tests__/update-payment-method.js | 99 +++ .../routes/store/carts/add-shipping-method.js | 39 ++ .../src/api/routes/store/carts/create-cart.js | 3 +- .../routes/store/carts/create-line-item.js | 35 ++ .../store/carts/create-payment-sessions.js | 18 + .../store/carts/create-shipping-options.js | 17 + .../src/api/routes/store/carts/get-cart.js | 4 +- .../src/api/routes/store/carts/index.js | 30 + .../src/api/routes/store/carts/update-cart.js | 3 +- .../routes/store/carts/update-line-item.js | 36 ++ .../store/carts/update-payment-method.js | 31 + packages/medusa/src/models/__mocks__/cart.js | 154 ++++- packages/medusa/src/models/cart.js | 2 + .../medusa/src/services/__mocks__/cart.js | 185 ++++++ .../src/services/__mocks__/line-item.js | 5 + .../services/__mocks__/payment-provider.js | 18 + .../medusa/src/services/__mocks__/region.js | 1 + .../src/services/__mocks__/shipping-option.js | 99 +++ .../medusa/src/services/__tests__/cart.js | 584 +++++++++++++++++- packages/medusa/src/services/cart.js | 252 +++++++- 31 files changed, 2091 insertions(+), 45 deletions(-) create mode 100644 docs/api/store/carts.md create mode 100644 packages/medusa/src/api/routes/store/carts/__tests__/add-shipping-method.js create mode 100644 packages/medusa/src/api/routes/store/carts/__tests__/create-line-item.js create mode 100644 packages/medusa/src/api/routes/store/carts/__tests__/create-payment-sessions.js create mode 100644 packages/medusa/src/api/routes/store/carts/__tests__/create-shipping-options.js create mode 100644 packages/medusa/src/api/routes/store/carts/__tests__/update-line-item.js create mode 100644 packages/medusa/src/api/routes/store/carts/__tests__/update-payment-method.js create mode 100644 packages/medusa/src/api/routes/store/carts/add-shipping-method.js create mode 100644 packages/medusa/src/api/routes/store/carts/create-line-item.js create mode 100644 packages/medusa/src/api/routes/store/carts/create-payment-sessions.js create mode 100644 packages/medusa/src/api/routes/store/carts/create-shipping-options.js create mode 100644 packages/medusa/src/api/routes/store/carts/update-line-item.js create mode 100644 packages/medusa/src/api/routes/store/carts/update-payment-method.js create mode 100644 packages/medusa/src/services/__mocks__/shipping-option.js diff --git a/docs/api/store/carts.md b/docs/api/store/carts.md new file mode 100644 index 0000000000..c3c061be2b --- /dev/null +++ b/docs/api/store/carts.md @@ -0,0 +1,9 @@ +POST /carts +GET /carts/:id +POST /carts/:id + +POST /carts/:id/line-items +POST /carts/:id/line-items/:line_id + +GET /carts/:id/shipping-options +POST /carts/:id/payment-sessions diff --git a/docs/services/CartService.md b/docs/services/CartService.md index dc73079466..963bb0a0b9 100644 --- a/docs/services/CartService.md +++ b/docs/services/CartService.md @@ -50,7 +50,7 @@ removes the line item with the given line id by calling `removeLineItem`. ### Initializing the checkout When the customer is ready to check out they will reach your checkout page. At this point you want to display which payment and shipping options are offered. -`POST /cart/payment-providers` and `POST /cart/fulfillment-providers` will +`POST /cart/payment-sessions` and `POST /cart/fulfillment-sessions` will initialize payment sessions and shipping methods offered by fulfillment providers. The payment sessions will typically have to be updated if any further changes to the cart total happens (shipping fee, promo codes, user exits @@ -93,3 +93,89 @@ function `setPaymentMethod` which will fetch the cart, search the check that the payment session is authorized. When the authorization is verified the payment method is set and the controller can safely call the Order service function `create`. + + +### How do payment sessions work? + +When the customer first enters the checkout page you should initialize payment +sessions for each of the possible payment providers. This is done with a single +call to `POST /cart/payment-sessions`. Calls to `POST /cart/payment-sessions` +will either create payment sessions for each payment provider or if the payment +sessions have already been initialized ensure that the sessions are up to date ( +i.e. that the cart amount corresponds to the payment sessions' amounts). When +the customer reaches the payment part of the checkout (or alternatively when she +decides to use one of the payment providers as a checkout provider) the payment +method will be saved once authorized. + +### How do fulfillment sessions work? + +When the customer first enters the checkout page, fulfillment sessions should be +initialized. The fulfillment session is responsible for fetching shipping +options with a fulfillment provider. E.g. your store has an integration with +your 3PL as a fulfillment provider. The 3PL has 4 shipping options: standard, +express and fragile shipping as well as a parcel shop service where orders are +delivered to a local store. + +The store operator will have set up which shipping options are available in the +customer's region. I.e. the store operator may have created a shipping option +called Free Shipping, which uses the "Standard Shipping" method from the 3PL +integration, and which is free when the order value is above 100 USD. The store +operator may have also created an Express Shipping option which uses the +"Express Shipping" method from the 3PL integration and which costs 20 USD. +The store operator has also created a Fragile Shipping option which uses the +"Fragile Shipping" method from the 3PL integration and which has variable +pricing depending on the size of the shipment. The variable pricing is +calculated by the integration depending on cart. Finally, the store operator has +defined a parcel shop option, which uses the 3PL's parcel shop shipping method. +The customer needs to provide the ID of the local store that she wants her order +delivered to and the shipping method therefore takes some additional input to be +a valid shipping method for an order. + +When the customer enters the checkout page the `POST /cart/shipping-options` +call will fetch each of the shipping options that the store operator has set up. +Extending the above example, an array of shipping options would be stored in the +cart in the format: + +``` +[ + { + _id: [some-id], + provider_id: "3pl_integration", + name: "Free Shipping", + price: 0, + data: { + // This will contain data specific to the shipping method, i.e. the + // id that the 3PL needs in order to process the order with this shipping + // method + } + }, + { + _id: [some-id], + provider_id: "3pl_integration", + name: "Express Shipping", + price: 20, + data: { + // This will contain data specific to the shipping method, i.e. the + // id that the 3PL needs in order to process the order with this shipping + // method + } + }, + { + _id: [some-id], + provider_id: "3pl_integration", + name: "Fragile Shipping", + price: 120, // Calculated from the cart + data: { + // This will contain data specific to the shipping method, i.e. the + // id that the 3PL needs in order to process the order with this shipping + // method + } + }, + ... +] +``` + +If the customer changes her cart, all shipping options will be recalculated. For +example, if the customer removes something from the cart so that they no longer +qualify for free shipping, the free shipping method will be removed at the same +time the fragile shipping method's price will be updated. diff --git a/packages/medusa/src/api/middlewares/error-handler.js b/packages/medusa/src/api/middlewares/error-handler.js index d06fe5c0e0..661131ad87 100644 --- a/packages/medusa/src/api/middlewares/error-handler.js +++ b/packages/medusa/src/api/middlewares/error-handler.js @@ -7,6 +7,7 @@ export default () => { let statusCode = 500 switch (err.name) { + case MedusaError.Types.NOT_ALLOWED: case MedusaError.Types.INVALID_DATA: statusCode = 400 break diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/add-shipping-method.js b/packages/medusa/src/api/routes/store/carts/__tests__/add-shipping-method.js new file mode 100644 index 0000000000..3ebae954f4 --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/__tests__/add-shipping-method.js @@ -0,0 +1,166 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CartServiceMock } from "../../../../../services/__mocks__/cart" +import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item" + +describe("POST /store/carts/:id/shipping-methods", () => { + describe("successfully adds a shipping method", () => { + let subject + + beforeAll(async () => { + const cartId = IdMap.getId("fr-cart") + subject = await request( + "POST", + `/store/carts/${cartId}/shipping-methods`, + { + payload: { + option_id: IdMap.getId("freeShipping"), + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CartService retrieveShippingOption", () => { + expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1) + expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith( + IdMap.getId("fr-cart"), + IdMap.getId("freeShipping") + ) + }) + + it("calls CartService addShipping", () => { + expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1) + expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith( + IdMap.getId("fr-cart"), + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("fr-cart")) + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("successfully adds a shipping method with additional data", () => { + let subject + + beforeAll(async () => { + const cartId = IdMap.getId("fr-cart") + subject = await request( + "POST", + `/store/carts/${cartId}/shipping-methods`, + { + payload: { + option_id: IdMap.getId("freeShipping"), + data: { + extra_id: "id", + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CartService retrieveShippingOption", () => { + expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1) + expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith( + IdMap.getId("fr-cart"), + IdMap.getId("freeShipping") + ) + }) + + it("calls CartService addShipping", () => { + expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1) + expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith( + IdMap.getId("fr-cart"), + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + data: { + extra_id: "id", + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("fr-cart")) + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("additional data without overwriting", () => { + let subject + + beforeAll(async () => { + const cartId = IdMap.getId("emptyCart") + subject = await request( + "POST", + `/store/carts/${cartId}/shipping-methods`, + { + payload: { + option_id: IdMap.getId("withData"), + data: { + extra_id: "id", + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CartService retrieveShippingOption", () => { + expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledTimes(1) + expect(CartServiceMock.retrieveShippingOption).toHaveBeenCalledWith( + IdMap.getId("emptyCart"), + IdMap.getId("withData") + ) + }) + + it("calls CartService addShipping", () => { + expect(CartServiceMock.addShippingMethod).toHaveBeenCalledTimes(1) + expect(CartServiceMock.addShippingMethod).toHaveBeenCalledWith( + IdMap.getId("emptyCart"), + { + _id: IdMap.getId("withData"), + profile_id: "default_profile", + data: { + extra_id: "id", + some_data: "yes", + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("emptyCart")) + expect(subject.body.decorated).toEqual(true) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/create-cart.js b/packages/medusa/src/api/routes/store/carts/__tests__/create-cart.js index 70bcdff74f..bbee1f41e8 100644 --- a/packages/medusa/src/api/routes/store/carts/__tests__/create-cart.js +++ b/packages/medusa/src/api/routes/store/carts/__tests__/create-cart.js @@ -26,12 +26,13 @@ describe("POST /store/carts", () => { }) }) - it("returns 201", () => { - expect(subject.status).toEqual(201) + it("returns 200", () => { + expect(subject.status).toEqual(200) }) it("returns the cart", () => { expect(subject.body._id).toEqual(IdMap.getId("regionCart")) + expect(subject.body.decorated).toEqual(true) }) }) @@ -97,8 +98,8 @@ describe("POST /store/carts", () => { jest.clearAllMocks() }) - it("returns 201", () => { - expect(subject.status).toEqual(201) + it("returns 200", () => { + expect(subject.status).toEqual(200) }) it("calls line item generate", () => { @@ -117,6 +118,7 @@ describe("POST /store/carts", () => { it("returns cart", () => { expect(subject.body._id).toEqual(IdMap.getId("regionCart")) + expect(subject.body.decorated).toEqual(true) }) }) diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/create-line-item.js b/packages/medusa/src/api/routes/store/carts/__tests__/create-line-item.js new file mode 100644 index 0000000000..5c511807f1 --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/__tests__/create-line-item.js @@ -0,0 +1,87 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CartServiceMock } from "../../../../../services/__mocks__/cart" +import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item" + +describe("POST /store/carts/:id", () => { + describe("successfully creates a line item", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/store/carts/${IdMap.getId("emptyCart")}/line-items`, + { + payload: { + variant_id: IdMap.getId("testVariant"), + quantity: 3, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CartService create", () => { + expect(CartServiceMock.addLineItem).toHaveBeenCalledTimes(1) + }) + + it("calls LineItemService generate", () => { + expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1) + expect(LineItemServiceMock.generate).toHaveBeenCalledWith( + IdMap.getId("testVariant"), + 3, + IdMap.getId("testRegion") + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("emptyCart")) + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("handles unsuccessful line item generation", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/store/carts/${IdMap.getId("emptyCart")}/line-items`, + { + payload: { + variant_id: IdMap.getId("fail"), + quantity: 3, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls LineItemService generate", () => { + expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1) + expect(LineItemServiceMock.generate).toHaveBeenCalledWith( + IdMap.getId("fail"), + 3, + IdMap.getId("testRegion") + ) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns error", () => { + expect(subject.body.message).toEqual("Doesn't exist") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/create-payment-sessions.js b/packages/medusa/src/api/routes/store/carts/__tests__/create-payment-sessions.js new file mode 100644 index 0000000000..fd7fbe288b --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/__tests__/create-payment-sessions.js @@ -0,0 +1,33 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CartServiceMock } from "../../../../../services/__mocks__/cart" + +describe("POST /store/carts/:id/payment-sessions", () => { + describe("creates payment sessions", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/store/carts/${IdMap.getId("emptyCart")}/payment-sessions` + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls Cart service set payment sessions", () => { + expect(CartServiceMock.setPaymentSessions).toHaveBeenCalledTimes(1) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("emptyCart")) + expect(subject.body.decorated).toEqual(true) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/create-shipping-options.js b/packages/medusa/src/api/routes/store/carts/__tests__/create-shipping-options.js new file mode 100644 index 0000000000..85a220f8a9 --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/__tests__/create-shipping-options.js @@ -0,0 +1,33 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CartServiceMock } from "../../../../../services/__mocks__/cart" + +describe("POST /store/carts/:id/shipping-options", () => { + describe("creates shipping options", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/store/carts/${IdMap.getId("emptyCart")}/shipping-options` + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls Cart service set shipping options", () => { + expect(CartServiceMock.setShippingOptions).toHaveBeenCalledTimes(1) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("emptyCart")) + expect(subject.body.decorated).toEqual(true) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/get-cart.js b/packages/medusa/src/api/routes/store/carts/__tests__/get-cart.js index 78826d19e8..2fa20a5afb 100644 --- a/packages/medusa/src/api/routes/store/carts/__tests__/get-cart.js +++ b/packages/medusa/src/api/routes/store/carts/__tests__/get-cart.js @@ -23,6 +23,7 @@ describe("GET /store/carts", () => { it("returns products", () => { expect(subject.body._id).toEqual(IdMap.getId("emptyCart")) + expect(subject.body.decorated).toEqual(true) }) }) 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 938c4d4696..d51403620b 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 @@ -86,6 +86,7 @@ describe("POST /store/carts/:id", () => { it("returns cart", () => { expect(subject.body._id).toEqual(IdMap.getId("emptyCart")) + expect(subject.body.decorated).toEqual(true) }) }) diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/update-line-item.js b/packages/medusa/src/api/routes/store/carts/__tests__/update-line-item.js new file mode 100644 index 0000000000..417dcef2ae --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/__tests__/update-line-item.js @@ -0,0 +1,92 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CartServiceMock } from "../../../../../services/__mocks__/cart" +import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item" + +describe("POST /store/carts/:id/line-items/:line_id", () => { + describe("successfully updates a line item", () => { + let subject + + beforeAll(async () => { + const cartId = IdMap.getId("emptyCart") + const lineId = IdMap.getId("existingLine") + subject = await request( + "POST", + `/store/carts/${cartId}/line-items/${lineId}`, + { + payload: { + variant_id: IdMap.getId("can-cover"), + quantity: 3, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CartService create", () => { + expect(CartServiceMock.updateLineItem).toHaveBeenCalledTimes(1) + }) + + it("calls LineItemService generate", () => { + expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1) + expect(LineItemServiceMock.generate).toHaveBeenCalledWith( + IdMap.getId("can-cover"), + 3, + IdMap.getId("testRegion") + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("emptyCart")) + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("handles unsuccessful line item generation", () => { + let subject + + beforeAll(async () => { + const cartId = IdMap.getId("emptyCart") + const lineId = IdMap.getId("existingLine") + + subject = await request( + "POST", + `/store/carts/${cartId}/line-items/${lineId}`, + { + payload: { + variant_id: IdMap.getId("fail"), + quantity: 3, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls LineItemService generate", () => { + expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1) + expect(LineItemServiceMock.generate).toHaveBeenCalledWith( + IdMap.getId("fail"), + 3, + IdMap.getId("testRegion") + ) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns error", () => { + expect(subject.body.message).toEqual("Doesn't exist") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/carts/__tests__/update-payment-method.js b/packages/medusa/src/api/routes/store/carts/__tests__/update-payment-method.js new file mode 100644 index 0000000000..1ac8f5b74b --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/__tests__/update-payment-method.js @@ -0,0 +1,99 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CartServiceMock } from "../../../../../services/__mocks__/cart" +import { LineItemServiceMock } from "../../../../../services/__mocks__/line-item" + +describe("POST /store/carts/:id/payment-method", () => { + describe("successfully sets the payment method", () => { + let subject + + beforeAll(async () => { + const cartId = IdMap.getId("cartWithPaySessions") + subject = await request("POST", `/store/carts/${cartId}/payment-method`, { + payload: { + provider_id: "default_provider", + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CartService retrievePaymentSession", () => { + expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledTimes(1) + expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledWith( + IdMap.getId("cartWithPaySessions"), + "default_provider" + ) + }) + + it("calls CartService setPaymentMethod", () => { + expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledTimes(1) + expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledWith( + IdMap.getId("cartWithPaySessions"), + { + provider_id: "default_provider", + data: { + money_id: "success", + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns the cart", () => { + expect(subject.body._id).toEqual(IdMap.getId("cartWithPaySessions")) + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("fails when pay session not authorized", () => { + let subject + + beforeAll(async () => { + const cartId = IdMap.getId("cartWithPaySessions") + subject = await request("POST", `/store/carts/${cartId}/payment-method`, { + payload: { + provider_id: "nono", + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CartService retrievePaymentSession", () => { + expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledTimes(1) + expect(CartServiceMock.retrievePaymentSession).toHaveBeenCalledWith( + IdMap.getId("cartWithPaySessions"), + "nono" + ) + }) + + it("calls CartService setPaymentMethod", () => { + expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledTimes(1) + expect(CartServiceMock.setPaymentMethod).toHaveBeenCalledWith( + IdMap.getId("cartWithPaySessions"), + { + provider_id: "nono", + data: { + money_id: "fail", + }, + } + ) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns the cart", () => { + expect(subject.body.message).toEqual("Not allowed") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/carts/add-shipping-method.js b/packages/medusa/src/api/routes/store/carts/add-shipping-method.js new file mode 100644 index 0000000000..fe2a018189 --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/add-shipping-method.js @@ -0,0 +1,39 @@ +import _ from "lodash" +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + option_id: Validator.string().required(), + data: Validator.object().optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const cartService = req.scope.resolve("cartService") + + const method = await cartService.retrieveShippingOption(id, value.option_id) + + // If the option accepts additional data this will be added + if (!_.isEmpty(value.data)) { + method.data = { + ...method.data, + ...value.data, + } + } + + await cartService.addShippingMethod(id, method) + + let cart = await cartService.retrieve(id) + cart = await cartService.decorate(cart) + + res.status(200).json(cart) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/carts/create-cart.js b/packages/medusa/src/api/routes/store/carts/create-cart.js index 166aa31643..d82fd3437b 100644 --- a/packages/medusa/src/api/routes/store/carts/create-cart.js +++ b/packages/medusa/src/api/routes/store/carts/create-cart.js @@ -35,7 +35,8 @@ export default async (req, res) => { } cart = await cartService.retrieve(cart._id) - res.status(201).json(cart) + cart = await cartService.decorate(cart) + res.status(200).json(cart) } catch (err) { throw err } diff --git a/packages/medusa/src/api/routes/store/carts/create-line-item.js b/packages/medusa/src/api/routes/store/carts/create-line-item.js new file mode 100644 index 0000000000..bb9a6fc69b --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/create-line-item.js @@ -0,0 +1,35 @@ +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + variant_id: Validator.string().required(), + quantity: Validator.number().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const lineItemService = req.scope.resolve("lineItemService") + const cartService = req.scope.resolve("cartService") + let cart = await cartService.retrieve(id) + + const lineItem = await lineItemService.generate( + value.variant_id, + value.quantity, + cart.region_id + ) + await cartService.addLineItem(cart._id, lineItem) + + cart = await cartService.retrieve(cart._id) + cart = await cartService.decorate(cart) + + res.status(200).json(cart) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/carts/create-payment-sessions.js b/packages/medusa/src/api/routes/store/carts/create-payment-sessions.js new file mode 100644 index 0000000000..202d2a675f --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/create-payment-sessions.js @@ -0,0 +1,18 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const cartService = req.scope.resolve("cartService") + + // Ask the cart service to set payment sessions + await cartService.setPaymentSessions(id) + + // return the updated cart + let cart = await cartService.retrieve(id) + cart = await cartService.decorate(cart) + + res.status(200).json(cart) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/carts/create-shipping-options.js b/packages/medusa/src/api/routes/store/carts/create-shipping-options.js new file mode 100644 index 0000000000..7f1e74f1cd --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/create-shipping-options.js @@ -0,0 +1,17 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const cartService = req.scope.resolve("cartService") + + // Ask the cart service to set payment sessions + await cartService.setShippingOptions(id) + + // return the updated cart + let cart = await cartService.retrieve(id) + cart = await cartService.decorate(cart) + res.status(200).json(cart) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/carts/get-cart.js b/packages/medusa/src/api/routes/store/carts/get-cart.js index 2d099cfdb9..34d45f6fa5 100644 --- a/packages/medusa/src/api/routes/store/carts/get-cart.js +++ b/packages/medusa/src/api/routes/store/carts/get-cart.js @@ -2,12 +2,14 @@ export default async (req, res) => { const { id } = req.params const cartService = req.scope.resolve("cartService") - const cart = await cartService.retrieve(id) + let cart = await cartService.retrieve(id) if (!cart) { res.sendStatus(404) return } + cart = await cartService.decorate(cart) + res.json(cart) } diff --git a/packages/medusa/src/api/routes/store/carts/index.js b/packages/medusa/src/api/routes/store/carts/index.js index d2e73c725f..3e9e9d0604 100644 --- a/packages/medusa/src/api/routes/store/carts/index.js +++ b/packages/medusa/src/api/routes/store/carts/index.js @@ -11,5 +11,35 @@ export default app => { route.post("/", middlewares.wrap(require("./create-cart").default)) route.post("/:id", middlewares.wrap(require("./update-cart").default)) + // Line items + route.post( + "/:id/line-items", + middlewares.wrap(require("./create-line-item").default) + ) + route.post( + "/:id/line-items/:line_id", + middlewares.wrap(require("./update-line-item").default) + ) + + // Payment sessions + route.post( + "/:id/payment-sessions", + middlewares.wrap(require("./create-payment-sessions").default) + ) + route.post( + "/:id/payment-method", + middlewares.wrap(require("./update-payment-method").default) + ) + + // Shipping Options + route.post( + "/:id/shipping-options", + middlewares.wrap(require("./create-shipping-options").default) + ) + route.post( + "/:id/shipping-methods", + middlewares.wrap(require("./add-shipping-method").default) + ) + return app } 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 bb841cae5c..756fa52655 100644 --- a/packages/medusa/src/api/routes/store/carts/update-cart.js +++ b/packages/medusa/src/api/routes/store/carts/update-cart.js @@ -51,7 +51,8 @@ export default async (req, res) => { ) } - const newCart = await cartService.retrieve(id) + let newCart = await cartService.retrieve(id) + newCart = await cartService.decorate(newCart) res.json(newCart) } catch (err) { throw err diff --git a/packages/medusa/src/api/routes/store/carts/update-line-item.js b/packages/medusa/src/api/routes/store/carts/update-line-item.js new file mode 100644 index 0000000000..152b6baf39 --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/update-line-item.js @@ -0,0 +1,36 @@ +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const { id, line_id } = req.params + + const schema = Validator.object().keys({ + variant_id: Validator.objectId().required(), + quantity: Validator.number().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const lineItemService = req.scope.resolve("lineItemService") + const cartService = req.scope.resolve("cartService") + let cart = await cartService.retrieve(id) + + const lineItem = await lineItemService.generate( + value.variant_id, + value.quantity, + cart.region_id + ) + + await cartService.updateLineItem(cart._id, line_id, lineItem) + + cart = await cartService.retrieve(cart._id) + cart = await cartService.decorate(cart) + + res.status(200).json(cart) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/carts/update-payment-method.js b/packages/medusa/src/api/routes/store/carts/update-payment-method.js new file mode 100644 index 0000000000..dafa842164 --- /dev/null +++ b/packages/medusa/src/api/routes/store/carts/update-payment-method.js @@ -0,0 +1,31 @@ +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + provider_id: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const cartService = req.scope.resolve("cartService") + + const session = await cartService.retrievePaymentSession( + id, + value.provider_id + ) + await cartService.setPaymentMethod(id, session) + + let cart = await cartService.retrieve(id) + cart = await cartService.decorate(cart) + + res.status(200).json(cart) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/models/__mocks__/cart.js b/packages/medusa/src/models/__mocks__/cart.js index 024783f93e..ed9beefa7d 100644 --- a/packages/medusa/src/models/__mocks__/cart.js +++ b/packages/medusa/src/models/__mocks__/cart.js @@ -6,8 +6,117 @@ export const carts = { title: "test", region_id: IdMap.getId("testRegion"), items: [], - shippingAddress: {}, - billingAddress: {}, + shipping_address: {}, + billing_address: {}, + discounts: [], + customer_id: "", + }, + withShippingOptions: { + _id: IdMap.getId("withShippingOptions"), + title: "test", + region_id: IdMap.getId("region-france"), + items: [], + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + name: "Free Shipping", + region_id: IdMap.getId("testRegion"), + price: 10, + provider_id: "test_shipper", + }, + { + _id: IdMap.getId("expensiveShipping"), + name: "Expensive Shipping", + region_id: IdMap.getId("testRegion"), + price: 100, + provider_id: "test_shipper", + }, + ], + shipping_address: {}, + billing_address: {}, + discounts: [], + customer_id: "", + }, + cartWithPaySessionsDifRegion: { + _id: IdMap.getId("cartWithPaySessionsDifRegion"), + region_id: IdMap.getId("region-france"), + items: [ + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + payment_sessions: [ + { + provider_id: "default_provider", + data: { + id: "default_provider_session", + }, + }, + { + provider_id: "unregistered", + data: { + id: "unregistered_session", + }, + }, + ], + shipping_address: {}, + billing_address: {}, + discounts: [], + customer_id: "", + }, + cartWithPaySessions: { + _id: IdMap.getId("cartWithPaySessions"), + region_id: IdMap.getId("testRegion"), + shipping_methods: [], + items: [ + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + payment_sessions: [ + { + provider_id: "default_provider", + data: { + id: "default_provider_session", + }, + }, + { + provider_id: "unregistered", + data: { + id: "unregistered_session", + }, + }, + ], + shipping_address: {}, + billing_address: {}, discounts: [], customer_id: "", }, @@ -34,8 +143,8 @@ export const carts = { quantity: 10, }, ], - shippingAddress: {}, - billingAddress: {}, + shipping_address: {}, + billing_address: {}, discounts: [], customer_id: "", }, @@ -50,12 +159,14 @@ export const carts = { yes: "sir", }, }, - shipping_method: { - provider_id: "gls", - data: { - yes: "sir", + shipping_methods: [ + { + provider_id: "gls", + data: { + yes: "sir", + }, }, - }, + ], shipping_address: { first_name: "hi", last_name: "you", @@ -127,8 +238,20 @@ export const carts = { quantity: 10, }, ], - shippingAddress: {}, - billingAddress: {}, + shipping_methods: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + shipping_address: {}, + billing_address: {}, discounts: [], customer_id: "", }, @@ -141,6 +264,15 @@ export const CartModelMock = { }), deleteOne: jest.fn().mockReturnValue(Promise.resolve()), findOne: jest.fn().mockImplementation(query => { + if (query._id === IdMap.getId("withShippingOptions")) { + return Promise.resolve(carts.withShippingOptions) + } + if (query._id === IdMap.getId("cartWithPaySessionsDifRegion")) { + return Promise.resolve(carts.cartWithPaySessionsDifRegion) + } + if (query._id === IdMap.getId("cartWithPaySessions")) { + return Promise.resolve(carts.cartWithPaySessions) + } if (query._id === IdMap.getId("emptyCart")) { return Promise.resolve(carts.emptyCart) } diff --git a/packages/medusa/src/models/cart.js b/packages/medusa/src/models/cart.js index 515f5bd6ee..53b20e3a4d 100644 --- a/packages/medusa/src/models/cart.js +++ b/packages/medusa/src/models/cart.js @@ -20,6 +20,8 @@ class CartModel extends BaseModel { region_id: { type: String, required: true }, discounts: { type: [String], default: [] }, customer_id: { type: String, default: "" }, + payment_sessions: { type: [PaymentMethodSchema], default: [] }, + shipping_options: { type: [ShippingMethodSchema], default: [] }, payment_method: { type: PaymentMethodSchema, default: {} }, shipping_methods: { type: [ShippingMethodSchema], default: [] }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, diff --git a/packages/medusa/src/services/__mocks__/cart.js b/packages/medusa/src/services/__mocks__/cart.js index 7472bae86c..1df0f0f538 100644 --- a/packages/medusa/src/services/__mocks__/cart.js +++ b/packages/medusa/src/services/__mocks__/cart.js @@ -5,12 +5,132 @@ export const carts = { emptyCart: { _id: IdMap.getId("emptyCart"), items: [], + region_id: IdMap.getId("testRegion"), + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + data: { + some_data: "yes", + }, + }, + ], }, regionCart: { _id: IdMap.getId("regionCart"), name: "Product 1", region_id: IdMap.getId("testRegion"), }, + frCart: { + _id: IdMap.getId("fr-cart"), + title: "test", + region_id: IdMap.getId("region-france"), + items: [ + { + _id: IdMap.getId("line"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: [ + { + unit_price: 8, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + ], + quantity: 10, + }, + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + shipping_methods: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + shipping_address: {}, + billing_address: {}, + discounts: [], + customer_id: "", + }, + cartWithPaySessions: { + _id: IdMap.getId("cartWithPaySessions"), + region_id: IdMap.getId("testRegion"), + items: [ + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + payment_sessions: [ + { + provider_id: "default_provider", + data: { + id: "default_provider_session", + }, + }, + { + provider_id: "unregistered", + data: { + id: "unregistered_session", + }, + }, + ], + shipping_address: {}, + billing_address: {}, + discounts: [], + customer_id: "", + }, } export const CartServiceMock = { @@ -23,17 +143,33 @@ export const CartServiceMock = { } }), retrieve: jest.fn().mockImplementation(cartId => { + if (cartId === IdMap.getId("fr-cart")) { + return Promise.resolve(carts.frCart) + } if (cartId === IdMap.getId("regionCart")) { return Promise.resolve(carts.regionCart) } if (cartId === IdMap.getId("emptyCart")) { return Promise.resolve(carts.emptyCart) } + if (cartId === IdMap.getId("cartWithPaySessions")) { + return Promise.resolve(carts.cartWithPaySessions) + } return Promise.resolve(undefined) }), addLineItem: jest.fn().mockImplementation((cartId, lineItem) => { return Promise.resolve() }), + setPaymentMethod: jest.fn().mockImplementation((cartId, method) => { + if (method.provider_id === "default_provider") { + return Promise.resolve() + } + + throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not allowed") + }), + updateLineItem: jest.fn().mockImplementation((cartId, lineItem) => { + return Promise.resolve() + }), setRegion: jest.fn().mockImplementation((cartId, regionId) => { if (regionId === IdMap.getId("fail")) { throw new MedusaError(MedusaError.Types.NOT_FOUND, "Region not found") @@ -52,6 +188,55 @@ export const CartServiceMock = { applyPromoCode: jest.fn().mockImplementation((cartId, code) => { return Promise.resolve() }), + setPaymentSessions: jest.fn().mockImplementation(cartId => { + return Promise.resolve() + }), + setShippingOptions: jest.fn().mockImplementation(cartId => { + return Promise.resolve() + }), + decorate: jest.fn().mockImplementation(cart => { + cart.decorated = true + return cart + }), + addShippingMethod: jest.fn().mockImplementation(cartId => { + return Promise.resolve() + }), + retrieveShippingOption: jest.fn().mockImplementation((cartId, optionId) => { + if (optionId === IdMap.getId("freeShipping")) { + return { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + } + } + if (optionId === IdMap.getId("withData")) { + return { + _id: IdMap.getId("withData"), + profile_id: "default_profile", + data: { + some_data: "yes", + }, + } + } + }), + retrievePaymentSession: jest.fn().mockImplementation((cartId, providerId) => { + if (providerId === "default_provider") { + return { + provider_id: "default_provider", + data: { + money_id: "success", + }, + } + } + + if (providerId === "nono") { + return { + provider_id: "nono", + data: { + money_id: "fail", + }, + } + } + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/__mocks__/line-item.js b/packages/medusa/src/services/__mocks__/line-item.js index 34b23c5562..5b9d5dbb8c 100644 --- a/packages/medusa/src/services/__mocks__/line-item.js +++ b/packages/medusa/src/services/__mocks__/line-item.js @@ -1,4 +1,5 @@ import { IdMap } from "medusa-test-utils" +import { MedusaError } from "medusa-core-utils" export const LineItemServiceMock = { validate: jest.fn().mockImplementation(data => { @@ -8,6 +9,10 @@ export const LineItemServiceMock = { return data }), generate: jest.fn().mockImplementation((variantId, quantity, regionId) => { + if (variantId === IdMap.getId("fail") || regionId === IdMap.getId("fail")) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, "Doesn't exist") + } + return Promise.resolve({ content: { variant: { diff --git a/packages/medusa/src/services/__mocks__/payment-provider.js b/packages/medusa/src/services/__mocks__/payment-provider.js index e9dd8334ce..1668abe33a 100644 --- a/packages/medusa/src/services/__mocks__/payment-provider.js +++ b/packages/medusa/src/services/__mocks__/payment-provider.js @@ -13,6 +13,24 @@ export const DefaultProviderMock = { } export const PaymentProviderServiceMock = { + updateSession: jest.fn().mockImplementation((session, cart) => { + return Promise.resolve({ + provider_id: session.provider_id, + data: { + ...session.data, + id: `${session.data.id}_updated`, + }, + }) + }), + createSession: jest.fn().mockImplementation((providerId, cart) => { + return Promise.resolve({ + provider_id: providerId, + data: { + id: `${providerId}_session`, + cartId: cart._id, + }, + }) + }), retrieveProvider: jest.fn().mockImplementation(providerId => { if (providerId === "default_provider") { return DefaultProviderMock diff --git a/packages/medusa/src/services/__mocks__/region.js b/packages/medusa/src/services/__mocks__/region.js index dd72b97bb5..541ff0dac8 100644 --- a/packages/medusa/src/services/__mocks__/region.js +++ b/packages/medusa/src/services/__mocks__/region.js @@ -14,6 +14,7 @@ export const regions = { _id: IdMap.getId("region-france"), name: "France", countries: ["FR"], + payment_providers: ["default_provider", "france-provider"], currency_code: "eur", }, regionUs: { diff --git a/packages/medusa/src/services/__mocks__/shipping-option.js b/packages/medusa/src/services/__mocks__/shipping-option.js new file mode 100644 index 0000000000..8ae10efa85 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/shipping-option.js @@ -0,0 +1,99 @@ +import { IdMap } from "medusa-test-utils" + +export const shippingOptions = { + freeShipping: { + _id: IdMap.getId("freeShipping"), + name: "Free Shipping", + region_id: IdMap.getId("testRegion"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "fs", + }, + price: { + type: "flat_rate", + amount: 10, + }, + provider_id: "test_shipper", + }, + expensiveShipping: { + _id: IdMap.getId("expensiveShipping"), + name: "Expensive Shipping", + profile_id: IdMap.getId("fragile-profile"), + region_id: IdMap.getId("testRegion"), + data: { + id: "es", + }, + price: { + type: "flat_rate", + amount: 100, + }, + provider_id: "test_shipper", + }, + franceShipping: { + _id: IdMap.getId("franceShipping"), + name: "FR Shipping", + profile_id: IdMap.getId("default-profile"), + region_id: IdMap.getId("region-france"), + data: { + id: "bonjour", + }, + price: { + type: "flat_rate", + amount: 20, + }, + provider_id: "test_shipper", + }, +} + +export const ShippingOptionServiceMock = { + retrieve: jest.fn().mockImplementation(optionId => { + if (optionId === IdMap.getId("freeShipping")) { + return Promise.resolve(shippingOptions.freeShipping) + } + return Promise.resolve(undefined) + }), + list: jest.fn().mockImplementation(data => { + if (data.region_id === IdMap.getId("region-france")) { + return Promise.resolve([shippingOptions.franceShipping]) + } + if (data.region_id === IdMap.getId("testRegion")) { + return Promise.resolve([ + shippingOptions.freeShipping, + shippingOptions.expensiveShipping, + ]) + } + }), + validateCartOption: jest.fn().mockImplementation((method, cart) => { + if (method._id === IdMap.getId("freeShipping")) { + return Promise.resolve(true) + } + if (method._id === IdMap.getId("franceShipping")) { + return Promise.resolve(true) + } + if (method._id === IdMap.getId("fail")) { + return Promise.resolve(false) + } + }), + fetchCartOptions: jest.fn().mockImplementation(cart => { + if (cart._id === IdMap.getId("cartWithLine")) { + return Promise.resolve([ + { + _id: IdMap.getId("freeShipping"), + name: "Free Shipping", + region_id: IdMap.getId("testRegion"), + price: 10, + data: { + id: "fs", + }, + provider_id: "test_shipper", + }, + ]) + } + }), +} + +const mock = jest.fn().mockImplementation(() => { + return ShippingOptionServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 2c2a08776b..de8ba86b93 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -7,6 +7,7 @@ import { } from "../__mocks__/payment-provider" import { ProductVariantServiceMock } from "../__mocks__/product-variant" import { RegionServiceMock } from "../__mocks__/region" +import { ShippingOptionServiceMock } from "../__mocks__/shipping-option" import { CartModelMock, carts } from "../../models/__mocks__/cart" import { LineItemServiceMock } from "../__mocks__/line-item" @@ -204,20 +205,6 @@ describe("CartService", () => { ) }) - it("throws if line item not validated", async () => { - const lineItem = { - title: "invalid lineitem", - description: "This is a new line", - thumbnail: "test-img-yeah.com/thumb", - } - - try { - await cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem) - } catch (err) { - expect(err.message).toEqual(`"content" is required`) - } - }) - it("throws if inventory isn't covered", async () => { const lineItem = { title: "merge line", @@ -285,6 +272,85 @@ describe("CartService", () => { }) }) + describe("updateLineItem", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + productVariantService: ProductVariantServiceMock, + lineItemService: LineItemServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully updates existing line item", async () => { + const lineItem = { + title: "update line", + description: "This is a new line", + thumbnail: "https://test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 2, + } + + await cartService.updateLineItem( + IdMap.getId("cartWithLine"), + IdMap.getId("existingLine"), + lineItem + ) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithLine"), + "items._id": IdMap.getId("existingLine"), + }, + { + $set: { "items.$": lineItem }, + } + ) + }) + + it("throws if inventory isn't covered", async () => { + const lineItem = { + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + quantity: 1, + content: { + variant: { + _id: IdMap.getId("cannot-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + unit_price: 1234, + }, + } + + try { + await cartService.updateLineItem( + IdMap.getId("cartWithLine"), + IdMap.getId("existingLine"), + lineItem + ) + } catch (err) { + expect(err.message).toEqual( + `Inventory doesn't cover the desired quantity` + ) + } + }) + }) + describe("updateEmail", () => { const cartService = new CartService({ cartModel: CartModelMock, @@ -459,6 +525,7 @@ describe("CartService", () => { { $set: { region_id: IdMap.getId("region-us"), + shipping_methods: [], items: [ { _id: IdMap.getId("line"), @@ -526,7 +593,7 @@ describe("CartService", () => { { $set: { region_id: IdMap.getId("region-us"), - shipping_method: undefined, + shipping_methods: [], payment_method: undefined, shipping_address: { first_name: "hi", @@ -711,4 +778,491 @@ describe("CartService", () => { } }) }) + + describe("setPaymentSessions", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + regionService: RegionServiceMock, + paymentProviderService: PaymentProviderServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("initializes payment sessions for each of the providers", async () => { + await cartService.setPaymentSessions(IdMap.getId("cartWithLine")) + + expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledTimes(2) + expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledWith( + "default_provider", + carts.cartWithLine + ) + expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledWith( + "unregistered", + carts.cartWithLine + ) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithLine"), + }, + { + $set: { + payment_sessions: [ + { + provider_id: "default_provider", + data: { + id: "default_provider_session", + cartId: IdMap.getId("cartWithLine"), + }, + }, + { + provider_id: "unregistered", + data: { + id: "unregistered_session", + cartId: IdMap.getId("cartWithLine"), + }, + }, + ], + }, + } + ) + }) + + it("updates payment sessions for existing sessions", async () => { + await cartService.setPaymentSessions(IdMap.getId("cartWithPaySessions")) + + expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledTimes(0) + + expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledTimes(2) + expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledWith( + { + provider_id: "default_provider", + data: { + id: "default_provider_session", + }, + }, + carts.cartWithPaySessions + ) + expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledWith( + { + provider_id: "unregistered", + data: { + id: "unregistered_session", + }, + }, + carts.cartWithPaySessions + ) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithPaySessions"), + }, + { + $set: { + payment_sessions: [ + { + provider_id: "default_provider", + data: { + id: "default_provider_session_updated", + }, + }, + { + provider_id: "unregistered", + data: { + id: "unregistered_session_updated", + }, + }, + ], + }, + } + ) + }) + + it("filters sessions not available in the region", async () => { + await cartService.setPaymentSessions( + IdMap.getId("cartWithPaySessionsDifRegion") + ) + + expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledTimes(1) + + expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledTimes(1) + expect(PaymentProviderServiceMock.updateSession).toHaveBeenCalledWith( + { + provider_id: "default_provider", + data: { + id: "default_provider_session", + }, + }, + carts.cartWithPaySessionsDifRegion + ) + expect(PaymentProviderServiceMock.createSession).toHaveBeenCalledWith( + "france-provider", + carts.cartWithPaySessionsDifRegion + ) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithPaySessionsDifRegion"), + }, + { + $set: { + payment_sessions: [ + { + provider_id: "default_provider", + data: { + id: "default_provider_session_updated", + }, + }, + { + provider_id: "france-provider", + data: { + id: "france-provider_session", + cartId: IdMap.getId("cartWithPaySessionsDifRegion"), + }, + }, + ], + }, + } + ) + }) + }) + + describe("setShippingOptions", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + regionService: RegionServiceMock, + shippingOptionService: ShippingOptionServiceMock, + }) + + describe("gets shipping options from the cart's regions", () => { + beforeAll(async () => { + jest.clearAllMocks() + await cartService.setShippingOptions(IdMap.getId("cartWithLine")) + }) + + it("gets shipping options from region", () => { + expect( + ShippingOptionServiceMock.fetchCartOptions + ).toHaveBeenCalledTimes(1) + expect(ShippingOptionServiceMock.fetchCartOptions).toHaveBeenCalledWith( + carts.cartWithLine + ) + }) + + it("updates cart", () => { + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithLine"), + }, + { + $set: { + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + name: "Free Shipping", + region_id: IdMap.getId("testRegion"), + price: 10, + data: { + id: "fs", + }, + provider_id: "test_shipper", + }, + ], + }, + } + ) + }) + }) + }) + + describe("retrievePaymentSession", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + }) + + let res + + describe("it retrieves the correct payment session", () => { + beforeAll(async () => { + jest.clearAllMocks() + res = await cartService.retrievePaymentSession( + IdMap.getId("cartWithPaySessions"), + "default_provider" + ) + }) + + it("retrieves the cart", () => { + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("cartWithPaySessions"), + }) + }) + + it("finds the correct payment session", () => { + expect(res.provider_id).toEqual("default_provider") + expect(res.data).toEqual({ + id: "default_provider_session", + }) + }) + }) + + describe("it fails when provider doesn't match open session", () => { + beforeAll(async () => { + jest.clearAllMocks() + try { + await cartService.retrievePaymentSession( + IdMap.getId("cartWithPaySessions"), + "nono" + ) + } catch (err) { + res = err + } + }) + + it("retrieves the cart", () => { + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("cartWithPaySessions"), + }) + }) + + it("throws invalid data errro", () => { + expect(res.message).toEqual( + "The provider_id did not match any open payment sessions" + ) + }) + }) + }) + + describe("addShippingMethod", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + shippingOptionService: ShippingOptionServiceMock, + }) + + describe("successfully adds the shipping method", () => { + const method = { + _id: IdMap.getId("freeShipping"), + provider_id: "test_shipper", + profile_id: "default_profile", + price: 20, + region_id: IdMap.getId("testRegion"), + data: { + id: "testshipperid", + }, + products: [IdMap.getId("product")], + } + + beforeAll(async () => { + jest.clearAllMocks() + const cartId = IdMap.getId("cartWithPaySessions") + await cartService.addShippingMethod(cartId, method) + }) + + it("checks availability", () => { + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledTimes(1) + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledWith(method, carts.cartWithPaySessions) + }) + + it("updates cart", () => { + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithPaySessions"), + }, + { + $set: { shipping_methods: [method] }, + } + ) + }) + }) + + describe("successfully overrides existing profile shipping method", () => { + const method = { + _id: IdMap.getId("freeShipping"), + provider_id: "test_shipper", + profile_id: "default_profile", + price: 20, + region_id: IdMap.getId("testRegion"), + data: { + id: "testshipperid", + }, + products: [IdMap.getId("product")], + } + + beforeAll(async () => { + jest.clearAllMocks() + const cartId = IdMap.getId("fr-cart") + await cartService.addShippingMethod(cartId, method) + }) + + it("checks availability", () => { + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledTimes(1) + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledWith(method, carts.frCart) + }) + + it("updates cart", () => { + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("fr-cart"), + }, + { + $set: { shipping_methods: [method] }, + } + ) + }) + }) + + describe("successfully adds additional shipping method", () => { + const method = { + _id: IdMap.getId("freeShipping"), + provider_id: "test_shipper", + profile_id: "additional_profile", + price: 20, + region_id: IdMap.getId("testRegion"), + data: { + id: "testshipperid", + }, + products: [IdMap.getId("product")], + } + + beforeAll(async () => { + jest.clearAllMocks() + const cartId = IdMap.getId("fr-cart") + await cartService.addShippingMethod(cartId, method) + }) + + it("checks availability", () => { + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledTimes(1) + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledWith(method, carts.frCart) + }) + + it("updates cart", () => { + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("fr-cart"), + }, + { + $set: { + shipping_methods: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + method, + ], + }, + } + ) + }) + }) + + describe("throws error on no availability", () => { + const method = { + _id: IdMap.getId("fail"), + } + + let res + beforeAll(async () => { + jest.clearAllMocks() + const cartId = IdMap.getId("fr-cart") + try { + await cartService.addShippingMethod(cartId, method) + } catch (err) { + res = err + } + }) + + it("checks availability", () => { + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledTimes(1) + expect( + ShippingOptionServiceMock.validateCartOption + ).toHaveBeenCalledWith(method, carts.frCart) + }) + + it("throw error", () => { + expect(res.message).toEqual( + "The selected shipping method cannot be applied to the cart" + ) + }) + }) + }) + + describe("retrieveShippingOption", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + }) + + let res + + describe("it retrieves the correct payment session", () => { + beforeAll(async () => { + jest.clearAllMocks() + res = await cartService.retrieveShippingOption( + IdMap.getId("fr-cart"), + IdMap.getId("freeShipping") + ) + }) + + it("retrieves the cart", () => { + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("fr-cart"), + }) + }) + + it("finds the correct payment session", () => { + expect(res._id).toEqual(IdMap.getId("freeShipping")) + }) + }) + + describe("it fails when provider doesn't match open session", () => { + beforeAll(async () => { + jest.clearAllMocks() + try { + await cartService.retrieveShippingOption( + IdMap.getId("fr-cart"), + "nono" + ) + } catch (err) { + res = err + } + }) + + it("retrieves the cart", () => { + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("fr-cart"), + }) + }) + + it("throws invalid data errro", () => { + expect(res.message).toEqual( + "The option id doesn't match any available shipping options" + ) + }) + }) + }) }) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index bebf165790..d6cc8b43b5 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -15,6 +15,7 @@ class CartService extends BaseService { productVariantService, regionService, lineItemService, + shippingOptionService, }) { super() @@ -38,6 +39,9 @@ class CartService extends BaseService { /** @private @const {PaymentProviderService} */ this.paymentProviderService_ = paymentProviderService + + /** @private @const {ShippingOptionsService} */ + this.shippingOptionService_ = shippingOptionService } /** @@ -200,9 +204,7 @@ class CartService extends BaseService { * @return {Cart} return the decorated cart. */ async decorate(cart, fields, expandFields = []) { - const requiredFields = ["_id", "metadata"] - const decorated = _.pick(cart, fields.concat(requiredFields)) - return decorated + return cart } /** @@ -213,9 +215,7 @@ class CartService extends BaseService { */ async addLineItem(cartId, lineItem) { const validatedLineItem = this.lineItemService_.validate(lineItem) - const cart = await this.retrieve(cartId) - const currentItem = cart.items.find(line => _.isEqual(line.content, validatedLineItem.content) ) @@ -275,6 +275,61 @@ class CartService extends BaseService { ) } + /** + * Updates a cart's existing line item. + * @param {string} cartId - the id of the cart to update + * @param {string} lineItemId - the id of the line item to update. + * @param {LineItem} lineItem - the line item to update. Must include an _id + * field. + * @return {Promise} the result of the update operation + */ + async updateLineItem(cartId, lineItemId, lineItem) { + const cart = await this.retrieve(cartId) + const validatedLineItem = this.lineItemService_.validate(lineItem) + + if (!lineItemId) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Line Item must have an _id corresponding to an existing line item id" + ) + } + + // Ensure that the line item exists in the cart + const lineItemExists = cart.items.find(i => i._id === lineItemId) + if (!lineItemExists) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "A line item with the provided id doesn't exist in the cart" + ) + } + + // Ensure that inventory covers the request + const hasInventory = await this.confirmInventory_( + validatedLineItem.content, + validatedLineItem.quantity + ) + + if (!hasInventory) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Inventory doesn't cover the desired quantity" + ) + } + + // Update the line item + return this.cartModel_.updateOne( + { + _id: cartId, + "items._id": lineItemId, + }, + { + $set: { + "items.$": validatedLineItem, + }, + } + ) + } + /** * Sets the email of a cart * @param {string} cartId - the id of the cart to add email to @@ -283,7 +338,6 @@ class CartService extends BaseService { */ async updateEmail(cartId, email) { const cart = await this.retrieve(cartId) - const schema = Validator.string() .email() .required() @@ -313,7 +367,6 @@ class CartService extends BaseService { */ async updateBillingAddress(cartId, address) { const cart = await this.retrieve(cartId) - const { value, error } = Validator.address().validate(address) if (error) { throw new MedusaError( @@ -340,7 +393,6 @@ class CartService extends BaseService { */ async updateShippingAddress(cartId, address) { const cart = await this.retrieve(cartId) - const { value, error } = Validator.address().validate(address) if (error) { throw new MedusaError( @@ -360,12 +412,37 @@ class CartService extends BaseService { } /** + * A payment method represents a way for the customer to pay. The payment + * method will typically come from one of the payment sessions. * @typedef {object} PaymentMethod * @property {string} provider_id - the identifier of the payment method's * provider * @property {object} data - the data associated with the payment method */ + /** + * Retrieves an open payment session from the list of payment sessions + * stored in the cart. If none is an INVALID_DATA error is thrown. + * @param {string} cartId - the id of the cart to retrieve the session from + * @param {string} providerId - the id of the provider the session belongs to + * @return {PaymentMethod} the session + */ + async retrievePaymentSession(cartId, providerId) { + const cart = await this.retrieve(cartId) + const session = cart.payment_sessions.find( + ({ provider_id }) => provider_id === providerId + ) + + if (!session) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The provider_id did not match any open payment sessions` + ) + } + + return session + } + /** * Sets a payment method for a cart. * @param {string} cartId - the id of the cart to add payment method to @@ -389,7 +466,8 @@ class CartService extends BaseService { ) } - // Check if the payment method has been authorized. + // The provider service will be able to perform operations on the + // session we are trying to set as the payment method. const provider = this.paymentProviderService_.retrieveProvider( paymentMethod.provider_id ) @@ -413,6 +491,158 @@ class CartService extends BaseService { ) } + /** + * Creates, updates and sets payment sessions associated with the cart. The + * first time the method is called payment sessions will be created for each + * provider. Additional calls will ensure that payment sessions have correct + * amounts, currencies, etc. as well as make sure to filter payment sessions + * that are not available for the cart's region. + * @param {string} cartId - the id of the cart to set payment session for + * @returns {Promise} the result of the update operation. + */ + async setPaymentSessions(cartId) { + const cart = await this.retrieve(cartId) + const region = await this.regionService_.retrieve(cart.region_id) + + // If there are existing payment sessions ensure that these are up to date + let sessions = [] + if (cart.payment_sessions && cart.payment_sessions.length) { + sessions = await Promise.all( + cart.payment_sessions.map(async pSession => { + if (!region.payment_providers.includes(pSession.provider_id)) { + return null + } + return this.paymentProviderService_.updateSession(pSession, cart) + }) + ) + } + + // Filter all null sessions + sessions = sessions.filter(s => !!s) + + // For all the payment providers in the region make sure to either skip them + // if they already exist or create them if they don't yet exist. + let newSessions = await Promise.all( + region.payment_providers.map(async pId => { + if (sessions.find(s => s.provider_id === pId)) { + return null + } + + return this.paymentProviderService_.createSession(pId, cart) + }) + ) + + // Filter null sessions + newSessions = newSessions.filter(s => !!s) + + // Update the payment sessions with the concatenated array of updated and + // newly created payment sessions + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $set: { payment_sessions: sessions.concat(newSessions) }, + } + ) + } + + /** + * Retrieves one of the open shipping options for the cart. + * @param {string} cartId - the id of the cart to retrieve the option from + * @param {string} optionId - the id of the option to retrieve + * @return {ShippingOption} the option that was found + */ + async retrieveShippingOption(cartId, optionId) { + const cart = await this.retrieve(cartId) + + const option = cart.shipping_options.find(({ _id }) => _id === optionId) + + if (!option) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The option id doesn't match any available shipping options` + ) + } + + return option + } + + /** + * Adds the shipping method to the list of shipping methods associated with + * the cart. + * @param {string} cartId - the id of the cart to add shipping method to + * @param {ShippingOption} method - the shipping method to add to the cart + * @return {Promise} the result of the update operation + */ + async addShippingMethod(cartId, method) { + const cart = await this.retrieve(cartId) + const { shipping_methods } = cart + + const isValid = await this.shippingOptionService_.validateCartOption( + method, + cart + ) + + if (!isValid) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "The selected shipping method cannot be applied to the cart" + ) + } + + // Go through all existing selected shipping methods and update the one + // that has the same profile as the selected shipping method. + let exists = false + const newMethods = shipping_methods.map(sm => { + if (sm.profile_id === method.profile_id) { + exists = true + return method + } + + return sm + }) + + // If none of the selected methods are for the same profile as the new + // shipping method the exists flag will be false. Therefore we push the new + // method. + if (!exists) { + newMethods.push(method) + } + + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $set: { shipping_methods: newMethods }, + } + ) + } + + /** + * Finds all shipping options that are available to the cart and stores them + * in shipping_options. The shipping options are retrieved from the shipping + * option service. + * @param {string} cartId - the id of the cart + * @return {Promse} the result of the update operation + */ + async setShippingOptions(cartId) { + const cart = await this.retrieve(cartId) + + // Get the shipping options available in the region + const cartOptions = await this.shippingOptionService_.fetchCartOptions(cart) + + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $set: { shipping_options: cartOptions }, + } + ) + } + /** * Set's the region of a cart. * @param {string} cartId - the id of the cart to set region on @@ -463,8 +693,8 @@ class CartService extends BaseService { // Shipping methods are determined by region so the user needs to find a // new shipping method - if (!_.isEmpty(cart.shipping_method)) { - update.shipping_method = undefined + if (cart.shipping_methods && cart.shipping_methods.length) { + update.shipping_methods = [] } // Payment methods are region specific so the user needs to find a