From 8255a839f6703ea70084025aecb635e2899a0b23 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 11 May 2020 13:54:09 +0200 Subject: [PATCH] Adds OrderService and /orders endpoints Adds OrderService Adds endpoints for orders - both store and admin --- packages/medusa/src/api/routes/admin/index.js | 2 + .../admin/orders/__tests__/archive-order.js | 40 ++ .../admin/orders/__tests__/cancel-order.js | 40 ++ .../admin/orders/__tests__/capture-payment.js | 40 ++ .../orders/__tests__/create-fulfillment.js | 40 ++ .../admin/orders/__tests__/create-order.js | 140 ++++++ .../admin/orders/__tests__/get-order.js | 39 ++ .../admin/orders/__tests__/return-order.js | 74 +++ .../admin/orders/__tests__/update-order.js | 63 +++ .../api/routes/admin/orders/archive-order.js | 11 + .../api/routes/admin/orders/cancel-order.js | 11 + .../routes/admin/orders/capture-payment.js | 11 + .../routes/admin/orders/create-fulfillment.js | 11 + .../api/routes/admin/orders/create-order.js | 46 ++ .../src/api/routes/admin/orders/get-order.js | 12 + .../src/api/routes/admin/orders/index.js | 30 ++ .../api/routes/admin/orders/return-order.js | 23 + .../api/routes/admin/orders/update-order.js | 40 ++ packages/medusa/src/api/routes/store/index.js | 2 + .../store/orders/__tests__/create-order.js | 32 ++ .../store/orders/__tests__/get-order.js | 31 ++ .../api/routes/store/orders/create-order.js | 39 ++ .../src/api/routes/store/orders/get-order.js | 27 + .../src/api/routes/store/orders/index.js | 14 + packages/medusa/src/models/__mocks__/order.js | 242 +++++++++ packages/medusa/src/models/order.js | 14 +- .../medusa/src/models/schemas/line-item.js | 1 + .../src/models/schemas/shipping-method.js | 2 + packages/medusa/src/models/shipping-option.js | 1 + .../medusa/src/services/__mocks__/order.js | 182 +++++++ .../services/__mocks__/payment-provider.js | 2 + .../medusa/src/services/__mocks__/region.js | 1 + .../services/__mocks__/shipping-profile.js | 16 +- .../medusa/src/services/__mocks__/totals.js | 6 + .../medusa/src/services/__tests__/order.js | 460 +++++++++++++++++ .../medusa/src/services/__tests__/totals.js | 271 +++++++++- packages/medusa/src/services/cart.js | 3 +- packages/medusa/src/services/order.js | 474 ++++++++++++++++++ packages/medusa/src/services/totals.js | 202 +++++++- 39 files changed, 2648 insertions(+), 47 deletions(-) create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/archive-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/cancel-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/capture-payment.js create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/create-fulfillment.js create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/create-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/return-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/__tests__/update-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/archive-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/cancel-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/capture-payment.js create mode 100644 packages/medusa/src/api/routes/admin/orders/create-fulfillment.js create mode 100644 packages/medusa/src/api/routes/admin/orders/create-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/get-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/index.js create mode 100644 packages/medusa/src/api/routes/admin/orders/return-order.js create mode 100644 packages/medusa/src/api/routes/admin/orders/update-order.js create mode 100644 packages/medusa/src/api/routes/store/orders/__tests__/create-order.js create mode 100644 packages/medusa/src/api/routes/store/orders/__tests__/get-order.js create mode 100644 packages/medusa/src/api/routes/store/orders/create-order.js create mode 100644 packages/medusa/src/api/routes/store/orders/get-order.js create mode 100644 packages/medusa/src/api/routes/store/orders/index.js create mode 100644 packages/medusa/src/models/__mocks__/order.js create mode 100644 packages/medusa/src/services/__mocks__/order.js create mode 100644 packages/medusa/src/services/__tests__/order.js create mode 100644 packages/medusa/src/services/order.js diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index d2143c3a9f..24ecceb802 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -8,6 +8,7 @@ import regionRoutes from "./regions" import shippingOptionRoutes from "./shipping-options" import shippingProfileRoutes from "./shipping-profiles" import discountRoutes from "./discounts" +import orderRoutes from "./orders" const route = Router() @@ -34,6 +35,7 @@ export default (app, container) => { shippingOptionRoutes(route) shippingProfileRoutes(route) discountRoutes(route) + orderRoutes(route) // productVariantRoutes(route) return app diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/archive-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/archive-order.js new file mode 100644 index 0000000000..2e1a1aa006 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/archive-order.js @@ -0,0 +1,40 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("POST /admin/orders/:id/archive", () => { + describe("successfully archives an order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/orders/${IdMap.getId("processed-order")}/archive`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls OrderService archive", () => { + expect(OrderServiceMock.archive).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.archive).toHaveBeenCalledWith( + IdMap.getId("processed-order") + ) + }) + + it("returns order with status = archived", () => { + expect(subject.status).toEqual(200) + expect(subject.body._id).toEqual(IdMap.getId("processed-order")) + expect(subject.body.status).toEqual("archived") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/cancel-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/cancel-order.js new file mode 100644 index 0000000000..2053e9470e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/cancel-order.js @@ -0,0 +1,40 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("POST /admin/orders/:id/cancel", () => { + describe("successfully cancels an order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/orders/${IdMap.getId("test-order")}/cancel`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls OrderService cancel", () => { + expect(OrderServiceMock.cancel).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.cancel).toHaveBeenCalledWith( + IdMap.getId("test-order") + ) + }) + + it("returns order with status = cancelled", () => { + expect(subject.status).toEqual(200) + expect(subject.body._id).toEqual(IdMap.getId("test-order")) + expect(subject.body.status).toEqual("cancelled") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/capture-payment.js b/packages/medusa/src/api/routes/admin/orders/__tests__/capture-payment.js new file mode 100644 index 0000000000..b5d85cf114 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/capture-payment.js @@ -0,0 +1,40 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("POST /admin/orders/:id/capture", () => { + describe("successfully captures payment for an order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/orders/${IdMap.getId("test-order")}/capture`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls OrderService capturePayment", () => { + expect(OrderServiceMock.capturePayment).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.capturePayment).toHaveBeenCalledWith( + IdMap.getId("test-order") + ) + }) + + it("returns order with payment_status = captured", () => { + expect(subject.status).toEqual(200) + expect(subject.body._id).toEqual(IdMap.getId("test-order")) + expect(subject.body.payment_status).toEqual("captured") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/create-fulfillment.js b/packages/medusa/src/api/routes/admin/orders/__tests__/create-fulfillment.js new file mode 100644 index 0000000000..b4ff08d185 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/create-fulfillment.js @@ -0,0 +1,40 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("POST /admin/orders/:id/fulfillment", () => { + describe("successfully fulfills an order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/orders/${IdMap.getId("test-order")}/fulfillment`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls OrderService createFulfillment", () => { + expect(OrderServiceMock.createFulfillment).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.createFulfillment).toHaveBeenCalledWith( + IdMap.getId("test-order") + ) + }) + + it("returns order with fulfillment_status = fulfilled", () => { + expect(subject.status).toEqual(200) + expect(subject.body._id).toEqual(IdMap.getId("test-order")) + expect(subject.body.fulfillment_status).toEqual("fulfilled") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/create-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/create-order.js new file mode 100644 index 0000000000..f210242999 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/create-order.js @@ -0,0 +1,140 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { + orders, + OrderServiceMock, +} from "../../../../../services/__mocks__/order" + +describe("POST /admin/orders", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", "/admin/orders", { + payload: { + email: "virgil@vandijk.dk", + billing_address: { + first_name: "Virgil", + last_name: "Van Dijk", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + shipping_address: { + first_name: "Virgil", + last_name: "Van Dijk", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + 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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region: IdMap.getId("testRegion"), + customer_id: IdMap.getId("testCustomer"), + payment_method: { + provider_id: "default_provider", + data: {}, + }, + shipping_method: [ + { + provider_id: "default_provider", + profile_id: IdMap.getId("validId"), + price: 123, + data: {}, + items: [], + }, + ], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls OrderService create", () => { + expect(OrderServiceMock.create).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.create).toHaveBeenCalledWith({ + email: "virgil@vandijk.dk", + billing_address: { + first_name: "Virgil", + last_name: "Van Dijk", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + shipping_address: { + first_name: "Virgil", + last_name: "Van Dijk", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + 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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region: IdMap.getId("testRegion"), + customer_id: IdMap.getId("testCustomer"), + payment_method: { + provider_id: "default_provider", + data: {}, + }, + shipping_method: [ + { + provider_id: "default_provider", + profile_id: IdMap.getId("validId"), + price: 123, + data: {}, + items: [], + }, + ], + }) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js new file mode 100644 index 0000000000..6acee42cd5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js @@ -0,0 +1,39 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("GET /admin/orders", () => { + describe("successfully gets an order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "GET", + `/admin/orders/${IdMap.getId("test-order")}`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls orderService retrieve", () => { + expect(OrderServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("test-order") + ) + }) + + it("returns order", () => { + expect(subject.status).toEqual(200) + expect(subject.body._id).toEqual(IdMap.getId("test-order")) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/return-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/return-order.js new file mode 100644 index 0000000000..2b935fd227 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/return-order.js @@ -0,0 +1,74 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("POST /admin/orders/:id/return", () => { + describe("successfully returns full order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/orders/${IdMap.getId("test-order")}/return`, + { + payload: { + 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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls OrderService return", () => { + expect(OrderServiceMock.return).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.return).toHaveBeenCalledWith( + IdMap.getId("test-order"), + [ + { + _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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ] + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/update-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/update-order.js new file mode 100644 index 0000000000..4c2bbe9d39 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/update-order.js @@ -0,0 +1,63 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("POST /admin/orders/:id", () => { + describe("successfully updates an order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/orders/${IdMap.getId("test-order")}`, + { + payload: { + email: "oliver@test.dk", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls OrderService update", () => { + expect(OrderServiceMock.update).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.update).toHaveBeenCalledWith( + IdMap.getId("test-order"), + { + email: "oliver@test.dk", + } + ) + }) + }) + + describe("handles failed update operation", () => { + it("throws if metadata is to be updated", async () => { + try { + await request("POST", `/admin/orders/${IdMap.getId("test-order")}`, { + payload: { + _id: IdMap.getId("test-order"), + metadata: "Test Description", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + } catch (error) { + expect(error.status).toEqual(400) + expect(error.message).toEqual( + "Use setMetadata to update metadata fields" + ) + } + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/orders/archive-order.js b/packages/medusa/src/api/routes/admin/orders/archive-order.js new file mode 100644 index 0000000000..9afaf1e7ef --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/archive-order.js @@ -0,0 +1,11 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.archive(id) + res.json(order) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/cancel-order.js b/packages/medusa/src/api/routes/admin/orders/cancel-order.js new file mode 100644 index 0000000000..2fe6d7d01b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/cancel-order.js @@ -0,0 +1,11 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.cancel(id) + res.json(order) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/capture-payment.js b/packages/medusa/src/api/routes/admin/orders/capture-payment.js new file mode 100644 index 0000000000..f6e5821a70 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/capture-payment.js @@ -0,0 +1,11 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.capturePayment(id) + res.json(order) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/create-fulfillment.js b/packages/medusa/src/api/routes/admin/orders/create-fulfillment.js new file mode 100644 index 0000000000..a4ca371889 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/create-fulfillment.js @@ -0,0 +1,11 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.createFulfillment(id) + res.json(order) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/create-order.js b/packages/medusa/src/api/routes/admin/orders/create-order.js new file mode 100644 index 0000000000..0c2b1ea06b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/create-order.js @@ -0,0 +1,46 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const schema = Validator.object().keys({ + status: Validator.string().optional(), + email: Validator.string() + .email() + .required(), + billing_address: Validator.address().required(), + shipping_address: Validator.address().required(), + items: Validator.array().required(), + region: Validator.string().required(), + discounts: Validator.array().optional(), + customer_id: Validator.string().required(), + payment_method: Validator.object() + .keys({ + provider_id: Validator.string().required(), + data: Validator.object().optional(), + }) + .required(), + shipping_method: Validator.array() + .items({ + provider_id: Validator.string().required(), + profile_id: Validator.string().required(), + price: Validator.number().required(), + data: Validator.object().optional(), + items: Validator.array().optional(), + }) + .required(), + metadata: Validator.object().optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.create(value) + + res.status(200).json(order) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/get-order.js b/packages/medusa/src/api/routes/admin/orders/get-order.js new file mode 100644 index 0000000000..48e175c325 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/get-order.js @@ -0,0 +1,12 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.retrieve(id) + + res.json(order) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/index.js b/packages/medusa/src/api/routes/admin/orders/index.js new file mode 100644 index 0000000000..0d811aaa12 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/index.js @@ -0,0 +1,30 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/orders", route) + + route.get("/:id", middlewares.wrap(require("./get-order").default)) + + route.post("/", middlewares.wrap(require("./create-order").default)) + route.post("/:id", middlewares.wrap(require("./update-order").default)) + + route.post( + "/:id/capture", + middlewares.wrap(require("./capture-payment").default) + ) + route.post( + "/:id/fulfillment", + middlewares.wrap(require("./create-fulfillment").default) + ) + route.post("/:id/return", middlewares.wrap(require("./return-order").default)) + route.post("/:id/cancel", middlewares.wrap(require("./cancel-order").default)) + route.post( + "/:id/archive", + middlewares.wrap(require("./archive-order").default) + ) + + return app +} diff --git a/packages/medusa/src/api/routes/admin/orders/return-order.js b/packages/medusa/src/api/routes/admin/orders/return-order.js new file mode 100644 index 0000000000..55360951bf --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/return-order.js @@ -0,0 +1,23 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + items: Validator.array().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.return(id, value.items) + + res.status(200).json(order) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/update-order.js b/packages/medusa/src/api/routes/admin/orders/update-order.js new file mode 100644 index 0000000000..5e35229d1e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/update-order.js @@ -0,0 +1,40 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + email: Validator.string().email(), + billing_address: Validator.address(), + shipping_address: Validator.address(), + items: Validator.array(), + region: Validator.string(), + discounts: Validator.array(), + customer_id: Validator.string(), + payment_method: Validator.object().keys({ + provider_id: Validator.string(), + data: Validator.object(), + }), + shipping_method: Validator.array().items({ + provider_id: Validator.string(), + profile_id: Validator.string(), + price: Validator.number(), + data: Validator.object(), + items: Validator.array(), + }), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const orderService = req.scope.resolve("orderService") + const order = await orderService.update(id, value) + + res.status(200).json(order) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index 98a711a38e..a81c02c9bf 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -2,6 +2,7 @@ import { Router } from "express" import productRoutes from "./products" import cartRoutes from "./carts" +import orderRoutes from "./orders" import customerRoutes from "./customers" import shippingOptionRoutes from "./shipping-options" @@ -12,6 +13,7 @@ export default app => { customerRoutes(route) productRoutes(route) + orderRoutes(route) cartRoutes(route) shippingOptionRoutes(route) diff --git a/packages/medusa/src/api/routes/store/orders/__tests__/create-order.js b/packages/medusa/src/api/routes/store/orders/__tests__/create-order.js new file mode 100644 index 0000000000..100f4a3a5f --- /dev/null +++ b/packages/medusa/src/api/routes/store/orders/__tests__/create-order.js @@ -0,0 +1,32 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" +import { carts } from "../../../../../services/__mocks__/cart" + +describe("POST /store/orders", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", "/store/orders", { + payload: { + cartId: IdMap.getId("fr-cart"), + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service create", () => { + expect(OrderServiceMock.create).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.create).toHaveBeenCalledWith(carts.frCart) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/orders/__tests__/get-order.js b/packages/medusa/src/api/routes/store/orders/__tests__/get-order.js new file mode 100644 index 0000000000..9b2136a119 --- /dev/null +++ b/packages/medusa/src/api/routes/store/orders/__tests__/get-order.js @@ -0,0 +1,31 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { OrderServiceMock } from "../../../../../services/__mocks__/order" + +describe("GET /store/orders", () => { + describe("successfully gets an order", () => { + let subject + + beforeAll(async () => { + subject = await request( + "GET", + `/store/orders/${IdMap.getId("test-order")}` + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls orderService retrieve", () => { + expect(OrderServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("test-order") + ) + }) + + it("returns order", () => { + expect(subject.body._id).toEqual(IdMap.getId("test-order")) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/orders/create-order.js b/packages/medusa/src/api/routes/store/orders/create-order.js new file mode 100644 index 0000000000..9a2cde41dd --- /dev/null +++ b/packages/medusa/src/api/routes/store/orders/create-order.js @@ -0,0 +1,39 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const schema = Validator.object().keys({ + cartId: 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 orderService = req.scope.resolve("orderService") + + const cart = await cartService.retrieve(value.cartId) + let order = await orderService.create(cart) + order = await orderService.decorate(order, [ + "status", + "fulfillment_status", + "payment_status", + "email", + "billing_address", + "shipping_address", + "items", + "region", + "discounts", + "customer_id", + "payment_method", + "shipping_methods", + "metadata", + ]) + + res.status(200).json(order) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/orders/get-order.js b/packages/medusa/src/api/routes/store/orders/get-order.js new file mode 100644 index 0000000000..d286cd0e6b --- /dev/null +++ b/packages/medusa/src/api/routes/store/orders/get-order.js @@ -0,0 +1,27 @@ +export default async (req, res) => { + const { id } = req.params + + try { + const orderService = req.scope.resolve("orderService") + let order = await orderService.retrieve(id) + order = await orderService.decorate(order, [ + "status", + "fulfillment_status", + "payment_status", + "email", + "billing_address", + "shipping_address", + "items", + "region", + "discounts", + "customer_id", + "payment_method", + "shipping_methods", + "metadata", + ]) + + res.json(order) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/store/orders/index.js b/packages/medusa/src/api/routes/store/orders/index.js new file mode 100644 index 0000000000..eadb53a301 --- /dev/null +++ b/packages/medusa/src/api/routes/store/orders/index.js @@ -0,0 +1,14 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/orders", route) + + route.get("/:id", middlewares.wrap(require("./get-order").default)) + + route.post("/", middlewares.wrap(require("./create-order").default)) + + return app +} diff --git a/packages/medusa/src/models/__mocks__/order.js b/packages/medusa/src/models/__mocks__/order.js new file mode 100644 index 0000000000..dff006bbcd --- /dev/null +++ b/packages/medusa/src/models/__mocks__/order.js @@ -0,0 +1,242 @@ +import { IdMap } from "medusa-test-utils" + +export const orders = { + testOrder: { + _id: IdMap.getId("test-order"), + email: "oliver@test.dk", + billing_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + shipping_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + 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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region: IdMap.getId("region-france"), + customer_id: IdMap.getId("test-customer"), + payment_method: { + provider_id: "default_provider", + }, + shipping_methods: [ + { + _id: IdMap.getId("expensiveShipping"), + name: "Expensive Shipping", + price: 100, + provider_id: "default_provider", + profile_id: IdMap.getId("default"), + }, + { + _id: IdMap.getId("freeShipping"), + name: "Free Shipping", + price: 10, + provider_id: "default_provider", + profile_id: IdMap.getId("profile1"), + }, + ], + fulfillment_status: "not_fulfilled", + payment_status: "awaiting", + status: "pending", + }, + processedOrder: { + _id: IdMap.getId("processed-order"), + email: "oliver@test.dk", + billing_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + shipping_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + 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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region: IdMap.getId("region-france"), + customer_id: IdMap.getId("test-customer"), + payment_method: { + provider_id: "default_provider", + }, + shipping_methods: [ + { + _id: IdMap.getId("expensiveShipping"), + name: "Expensive Shipping", + price: 100, + provider_id: "default_provider", + profile_id: IdMap.getId("default"), + }, + { + _id: IdMap.getId("freeShipping"), + name: "Free Shipping", + price: 10, + provider_id: "default_provider", + profile_id: IdMap.getId("profile1"), + }, + ], + fulfillment_status: "fulfilled", + payment_status: "captured", + status: "completed", + }, + orderToRefund: { + _id: IdMap.getId("refund-order"), + email: "oliver@test.dk", + billing_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + shipping_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + items: [ + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + { + _id: IdMap.getId("existingLine2"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region: IdMap.getId("region-france"), + customer_id: IdMap.getId("test-customer"), + payment_method: { + provider_id: "default_provider", + }, + shipping_methods: [ + { + provider_id: "default_provider", + profile_id: IdMap.getId("default"), + data: {}, + items: {}, + }, + { + provider_id: "default_provider", + profile_id: IdMap.getId("default"), + data: {}, + items: {}, + }, + ], + discounts: [], + }, +} + +export const OrderModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + updateOne: jest.fn().mockImplementation((query, update) => { + return Promise.resolve() + }), + deleteOne: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query._id === IdMap.getId("test-order")) { + orders.testOrder.payment_status = "awaiting" + return Promise.resolve(orders.testOrder) + } + if (query._id === IdMap.getId("not-fulfilled-order")) { + orders.testOrder.fulfillment_status = "not_fulfilled" + orders.testOrder.payment_status = "awaiting" + return Promise.resolve(orders.testOrder) + } + if (query._id === IdMap.getId("fulfilled-order")) { + orders.testOrder.fulfillment_status = "fulfilled" + return Promise.resolve(orders.testOrder) + } + if (query._id === IdMap.getId("payed-order")) { + orders.testOrder.fulfillment_status = "not_fulfilled" + orders.testOrder.payment_status = "captured" + return Promise.resolve(orders.testOrder) + } + if (query._id === IdMap.getId("processed-order")) { + return Promise.resolve(orders.processedOrder) + } + if (query._id === IdMap.getId("order-refund")) { + orders.orderToRefund.payment_status = "captured" + return Promise.resolve(orders.orderToRefund) + } + return Promise.resolve(undefined) + }), +} diff --git a/packages/medusa/src/models/order.js b/packages/medusa/src/models/order.js index e12bb22ceb..03cfb03371 100644 --- a/packages/medusa/src/models/order.js +++ b/packages/medusa/src/models/order.js @@ -1,6 +1,3 @@ -/******************************************************************************* - * - ******************************************************************************/ import mongoose from "mongoose" import { BaseModel } from "medusa-interfaces" @@ -8,19 +5,24 @@ import LineItemSchema from "./schemas/line-item" import PaymentMethodSchema from "./schemas/payment-method" import ShippingMethodSchema from "./schemas/shipping-method" import AddressSchema from "./schemas/address" +import DiscountModel from "./discount" class OrderModel extends BaseModel { static modelName = "Order" static schema = { - canceled: { type: Boolean, default: false }, - archived: { type: Boolean, default: false }, + // pending, completed, archived, cancelled + status: { type: String, default: "pending" }, + // not_fulfilled, partially_fulfilled (some line items have been returned), fulfilled, returned, + fulfillment_status: { type: String, default: "not_fulfilled" }, + // awaiting, captured, refunded + payment_status: { type: String, default: "awaiting" }, email: { type: String, required: true }, billing_address: { type: AddressSchema, required: true }, shipping_address: { type: AddressSchema, required: true }, items: { type: [LineItemSchema], required: true }, region: { type: String, required: true }, - discounts: { type: [String], default: [] }, + discounts: { type: [DiscountModel.schema], default: [] }, customer_id: { type: String, required: true }, payment_method: { type: PaymentMethodSchema, required: true }, shipping_methods: { type: [ShippingMethodSchema], required: true }, diff --git a/packages/medusa/src/models/schemas/line-item.js b/packages/medusa/src/models/schemas/line-item.js index 01b2f84c85..4409963643 100644 --- a/packages/medusa/src/models/schemas/line-item.js +++ b/packages/medusa/src/models/schemas/line-item.js @@ -33,5 +33,6 @@ export default new mongoose.Schema({ // cards etc. the unit_price field is provided to give more granular control. content: { type: mongoose.Schema.Types.Mixed, required: true }, quantity: { type: Number, required: true }, + returned: { type: Boolean, default: false }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, }) diff --git a/packages/medusa/src/models/schemas/shipping-method.js b/packages/medusa/src/models/schemas/shipping-method.js index 81ca02bda8..91d330fb62 100644 --- a/packages/medusa/src/models/schemas/shipping-method.js +++ b/packages/medusa/src/models/schemas/shipping-method.js @@ -2,6 +2,8 @@ import mongoose from "mongoose" export default new mongoose.Schema({ provider_id: { type: String, required: true }, + profile_id: { type: String, required: true }, + price: { type: Number, required: true }, data: { type: mongoose.Schema.Types.Mixed, default: {} }, items: { type: [mongoose.Schema.Types.Mixed], default: [] }, }) diff --git a/packages/medusa/src/models/shipping-option.js b/packages/medusa/src/models/shipping-option.js index 5603ac0ef3..d7b17834fc 100644 --- a/packages/medusa/src/models/shipping-option.js +++ b/packages/medusa/src/models/shipping-option.js @@ -10,6 +10,7 @@ class ShippingOptionModel extends BaseModel { name: { type: String, required: true }, region_id: { type: String, required: true }, provider_id: { type: String, required: true }, + profile_id: { type: String, required: true }, data: { type: mongoose.Schema.Types.Mixed, default: {} }, price: { type: ShippingOptionPrice, required: true }, requirements: { type: [ShippingOptionRequirement], default: [] }, diff --git a/packages/medusa/src/services/__mocks__/order.js b/packages/medusa/src/services/__mocks__/order.js new file mode 100644 index 0000000000..781cbbf565 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/order.js @@ -0,0 +1,182 @@ +import { IdMap } from "medusa-test-utils" + +export const orders = { + testOrder: { + _id: IdMap.getId("test-order"), + email: "virgil@vandijk.dk", + billing_address: { + first_name: "Virgil", + last_name: "Van Dijk", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + shipping_address: { + first_name: "Virgil", + last_name: "Van Dijk", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + 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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region: IdMap.getId("testRegion"), + customer_id: IdMap.getId("testCustomer"), + payment_method: { + provider_id: "default_provider", + data: {}, + }, + shipping_method: [ + { + provider_id: "default_provider", + profile_id: IdMap.getId("validId"), + data: {}, + items: {}, + }, + ], + }, + processedOrder: { + _id: IdMap.getId("processed-order"), + email: "oliver@test.dk", + billing_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + shipping_address: { + first_name: "Oli", + last_name: "Medusa", + address_1: "testaddress", + city: "LA", + country_code: "US", + postal_code: "90002", + }, + 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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region: IdMap.getId("region-france"), + customer_id: IdMap.getId("test-customer"), + payment_method: { + provider_id: "default_provider", + }, + shipping_methods: [ + { + _id: IdMap.getId("expensiveShipping"), + name: "Expensive Shipping", + price: 100, + provider_id: "default_provider", + profile_id: IdMap.getId("default"), + }, + { + _id: IdMap.getId("freeShipping"), + name: "Free Shipping", + price: 10, + provider_id: "default_provider", + profile_id: IdMap.getId("profile1"), + }, + ], + fulfillment_status: "fulfilled", + payment_status: "captured", + status: "completed", + }, +} + +export const OrderServiceMock = { + create: jest.fn().mockImplementation(data => { + return Promise.resolve(orders.testOrder) + }), + update: jest.fn().mockImplementation(data => Promise.resolve()), + retrieve: jest.fn().mockImplementation(orderId => { + if (orderId === IdMap.getId("test-order")) { + return Promise.resolve(orders.testOrder) + } + if (orderId === IdMap.getId("processed-order")) { + return Promise.resolve(orders.processedOrder) + } + return Promise.resolve(undefined) + }), + decorate: jest.fn().mockImplementation(order => { + order.decorated = true + return order + }), + cancel: jest.fn().mockImplementation(order => { + if (order === IdMap.getId("test-order")) { + orders.testOrder.status = "cancelled" + return Promise.resolve(orders.testOrder) + } + return Promise.resolve(undefined) + }), + archive: jest.fn().mockImplementation(order => { + if (order === IdMap.getId("processed-order")) { + orders.processedOrder.status = "archived" + return Promise.resolve(orders.processedOrder) + } + return Promise.resolve(undefined) + }), + createFulfillment: jest.fn().mockImplementation(order => { + if (order === IdMap.getId("test-order")) { + orders.testOrder.fulfillment_status = "fulfilled" + return Promise.resolve(orders.testOrder) + } + return Promise.resolve(undefined) + }), + capturePayment: jest.fn().mockImplementation(order => { + if (order === IdMap.getId("test-order")) { + orders.testOrder.payment_status = "captured" + return Promise.resolve(orders.testOrder) + } + return Promise.resolve(undefined) + }), + return: jest.fn().mockImplementation(order => { + if (order === IdMap.getId("test-order")) { + return Promise.resolve(orders.testOrder) + } + return Promise.resolve(undefined) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return OrderServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/payment-provider.js b/packages/medusa/src/services/__mocks__/payment-provider.js index 1668abe33a..3cd2b709cc 100644 --- a/packages/medusa/src/services/__mocks__/payment-provider.js +++ b/packages/medusa/src/services/__mocks__/payment-provider.js @@ -10,6 +10,8 @@ export const DefaultProviderMock = { return Promise.resolve("initial") }), + capturePayment: jest.fn().mockReturnValue(Promise.resolve()), + refundPayment: jest.fn().mockReturnValue(Promise.resolve()), } export const PaymentProviderServiceMock = { diff --git a/packages/medusa/src/services/__mocks__/region.js b/packages/medusa/src/services/__mocks__/region.js index b05902f4d1..7685b15711 100644 --- a/packages/medusa/src/services/__mocks__/region.js +++ b/packages/medusa/src/services/__mocks__/region.js @@ -17,6 +17,7 @@ export const regions = { payment_providers: ["default_provider", "france-provider"], fulfillment_providers: ["default_provider"], currency_code: "eur", + tax_rate: 0.25, }, regionUs: { _id: IdMap.getId("region-us"), diff --git a/packages/medusa/src/services/__mocks__/shipping-profile.js b/packages/medusa/src/services/__mocks__/shipping-profile.js index 721453a026..1751b6dff1 100644 --- a/packages/medusa/src/services/__mocks__/shipping-profile.js +++ b/packages/medusa/src/services/__mocks__/shipping-profile.js @@ -4,7 +4,13 @@ export const profiles = { default: { _id: IdMap.getId("default"), name: "default_profile", - products: [], + products: [IdMap.getId("product")], + shipping_options: [], + }, + other: { + _id: IdMap.getId("profile1"), + name: "other_profile", + products: [IdMap.getId("product")], shipping_options: [], }, } @@ -16,7 +22,13 @@ export const ShippingProfileServiceMock = { create: jest.fn().mockImplementation(data => { return Promise.resolve(data) }), - retrieve: jest.fn().mockImplementation(profileId => { + retrieve: jest.fn().mockImplementation(data => { + if (data === IdMap.getId("default")) { + return Promise.resolve(profiles.default) + } + if (data === IdMap.getId("profile1")) { + return Promise.resolve(profiles.other) + } return Promise.resolve() }), list: jest.fn().mockImplementation(selector => { diff --git a/packages/medusa/src/services/__mocks__/totals.js b/packages/medusa/src/services/__mocks__/totals.js index c7e6e70255..ef7e7ca99e 100644 --- a/packages/medusa/src/services/__mocks__/totals.js +++ b/packages/medusa/src/services/__mocks__/totals.js @@ -10,6 +10,12 @@ export const TotalsServiceMock = { } return 0 }), + getRefundTotal: jest.fn().mockImplementation((order, lineItems) => { + if (order._id === IdMap.getId("processed-order")) { + return 1230 + } + return 0 + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js new file mode 100644 index 0000000000..69b9c56731 --- /dev/null +++ b/packages/medusa/src/services/__tests__/order.js @@ -0,0 +1,460 @@ +import { IdMap } from "medusa-test-utils" +import { OrderModelMock, orders } from "../../models/__mocks__/order" +import OrderService from "../order" +import { PaymentProviderServiceMock } from "../__mocks__/payment-provider" +import { FulfillmentProviderServiceMock } from "../__mocks__/fulfillment-provider" +import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile" +import { TotalsServiceMock } from "../__mocks__/totals" + +describe("OrderService", () => { + describe("create", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.create({ + email: "oliver@test.dk", + }) + + expect(OrderModelMock.create).toHaveBeenCalledTimes(1) + expect(OrderModelMock.create).toHaveBeenCalledWith({ + email: "oliver@test.dk", + }) + }) + }) + + describe("retrieve", () => { + let result + const orderService = new OrderService({ + orderModel: OrderModelMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + result = await orderService.retrieve(IdMap.getId("test-order")) + }) + + it("calls order model functions", async () => { + expect(OrderModelMock.findOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("test-order"), + }) + }) + + it("returns correct order", async () => { + expect(result._id).toEqual(IdMap.getId("test-order")) + }) + }) + + describe("update", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.update(IdMap.getId("test-order"), { + email: "oliver@test.dk", + }) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("test-order") }, + { + $set: { + email: "oliver@test.dk", + }, + }, + { runValidators: true } + ) + }) + + it("throws on invalid billing address", async () => { + const address = { + last_name: "James", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + } + + try { + await orderService.update(IdMap.getId("test-order"), { + billing_address: address, + }) + } catch (err) { + expect(err.message).toEqual("The address is not valid") + } + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(0) + }) + + it("throws on invalid shipping address", async () => { + const address = { + last_name: "James", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + } + + try { + await orderService.update(IdMap.getId("test-order"), { + shipping_address: address, + }) + } catch (err) { + expect(err.message).toEqual("The address is not valid") + } + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(0) + }) + + it("throws if metadata update are attempted", async () => { + try { + await orderService.update(IdMap.getId("test-order"), { + metadata: { test: "foo" }, + }) + } catch (error) { + expect(error.message).toEqual( + "Use setMetadata to update metadata fields" + ) + } + }) + + it("throws if address updates are attempted after fulfillment", async () => { + try { + await orderService.update(IdMap.getId("fulfilled-order"), { + billing_address: { + first_name: "Lebron", + last_name: "James", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + }) + } catch (error) { + expect(error.message).toEqual( + "Can't update shipping, billing, items and payment method when order is processed" + ) + } + }) + + it("throws if payment method update is attempted after fulfillment", async () => { + try { + await orderService.update(IdMap.getId("fulfilled-order"), { + payment_method: { + provider_id: "test", + profile_id: "test", + }, + }) + } catch (error) { + expect(error.message).toEqual( + "Can't update shipping, billing, items and payment method when order is processed" + ) + } + }) + + it("throws if items update is attempted after fulfillment", async () => { + try { + await orderService.update(IdMap.getId("fulfilled-order"), { + items: [], + }) + } catch (error) { + expect(error.message).toEqual( + "Can't update shipping, billing, items and payment method when order is processed" + ) + } + }) + }) + + describe("cancel", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.cancel(IdMap.getId("not-fulfilled-order")) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("not-fulfilled-order") }, + { $set: { status: "cancelled" } } + ) + }) + + it("throws if order is fulfilled", async () => { + try { + await orderService.cancel(IdMap.getId("fulfilled-order")) + } catch (error) { + expect(error.message).toEqual("Can't cancel a fulfilled order") + } + }) + + it("throws if order payment is captured", async () => { + try { + await orderService.cancel(IdMap.getId("payed-order")) + } catch (error) { + expect(error.message).toEqual( + "Can't cancel an order with payment processed" + ) + } + }) + }) + + describe("capturePayment", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + paymentProviderService: PaymentProviderServiceMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.capturePayment(IdMap.getId("test-order")) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("test-order") }, + { $set: { payment_status: "captured" } } + ) + }) + + it("throws if payment is already processed", async () => { + try { + await orderService.capturePayment(IdMap.getId("payed-order")) + } catch (error) { + expect(error.message).toEqual("Payment already captured") + } + }) + }) + + describe("createFulfillment", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + shippingProfileService: ShippingProfileServiceMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.createFulfillment(IdMap.getId("test-order")) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("test-order") }, + { $set: { fulfillment_status: "fulfilled" } } + ) + }) + + it("throws if payment is already processed", async () => { + try { + await orderService.createFulfillment(IdMap.getId("fulfilled-order")) + } catch (error) { + expect(error.message).toEqual("Order is already fulfilled") + } + }) + }) + + describe("return", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + paymentProviderService: PaymentProviderServiceMock, + totalsService: TotalsServiceMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.return(IdMap.getId("processed-order"), [ + { + _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("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ]) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("processed-order") }, + { + $set: { + items: [ + { + _id: IdMap.getId("existingLine"), + content: { + product: { + _id: IdMap.getId("validId"), + }, + quantity: 1, + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + }, + description: "This is a new line", + quantity: 10, + returned_quantity: 10, + thumbnail: "test-img-yeah.com/thumb", + title: "merge line", + }, + ], + fulfillment_status: "returned", + }, + } + ) + }) + + it("calls order model functions and sets partially_fulfilled", async () => { + await orderService.return(IdMap.getId("order-refund"), [ + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 2, + }, + ]) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("order-refund") }, + { + $set: { + items: [ + { + _id: IdMap.getId("existingLine"), + content: { + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + unit_price: 100, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + }, + description: "This is a new line", + quantity: 10, + returned_quantity: 2, + thumbnail: "test-img-yeah.com/thumb", + title: "merge line", + }, + { + _id: IdMap.getId("existingLine2"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + fulfillment_status: "partially_fulfilled", + }, + } + ) + }) + + it("throws if payment is already processed", async () => { + try { + await orderService.return(IdMap.getId("fulfilled-order")) + } catch (error) { + expect(error.message).toEqual( + "Can't return an order with payment unprocessed" + ) + } + }) + + it("throws if return is attempted on unfulfilled order", async () => { + try { + await orderService.return(IdMap.getId("not-fulfilled-order")) + } catch (error) { + expect(error.message).toEqual( + "Can't return an unfulfilled or already returned order" + ) + } + }) + }) + + describe("archive", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.archive(IdMap.getId("processed-order")) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("processed-order") }, + { $set: { status: "archived" } } + ) + }) + + it("throws if order is unprocessed", async () => { + try { + await orderService.archive(IdMap.getId("test-order")) + } catch (error) { + expect(error.message).toEqual("Can't archive an unprocessed order") + } + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/totals.js b/packages/medusa/src/services/__tests__/totals.js index 44f88815fb..2a7a2ab75f 100644 --- a/packages/medusa/src/services/__tests__/totals.js +++ b/packages/medusa/src/services/__tests__/totals.js @@ -2,7 +2,9 @@ import TotalsService from "../totals" import { ProductVariantServiceMock } from "../__mocks__/product-variant" import { discounts } from "../../models/__mocks__/discount" import { carts } from "../__mocks__/cart" +import { orders } from "../../models/__mocks__/order" import { IdMap } from "medusa-test-utils" +import { RegionServiceMock } from "../__mocks__/region" describe("TotalsService", () => { describe("getAllocationItemDiscounts", () => { @@ -23,9 +25,25 @@ describe("TotalsService", () => { expect(res).toEqual([ { - lineItem: IdMap.getId("existingLine"), + lineItem: { + _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, + }, variant: IdMap.getId("eur-10-us-12"), - amount: 1, + amount: 10, }, ]) }) @@ -38,9 +56,25 @@ describe("TotalsService", () => { expect(res).toEqual([ { - lineItem: IdMap.getId("existingLine"), + lineItem: { + _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, + }, variant: IdMap.getId("eur-10-us-12"), - amount: 9, + amount: 90, }, ]) }) @@ -70,41 +104,41 @@ describe("TotalsService", () => { carts.discountCart.discounts.push(discounts.total10Percent) res = await totalsService.getDiscountTotal(carts.discountCart) - expect(res).toEqual(252) + expect(res).toEqual(28) }) it("calculate item fixed discount", async () => { carts.discountCart.discounts.push(discounts.item2Fixed) res = await totalsService.getDiscountTotal(carts.discountCart) - expect(res).toEqual(278) + expect(res).toEqual(20) }) it("calculate item percentage discount", async () => { carts.discountCart.discounts.push(discounts.item10Percent) res = await totalsService.getDiscountTotal(carts.discountCart) - expect(res).toEqual(279) + expect(res).toEqual(10) }) it("calculate total fixed discount", async () => { carts.discountCart.discounts.push(discounts.total10Fixed) res = await totalsService.getDiscountTotal(carts.discountCart) - expect(res).toEqual(270) + expect(res).toEqual(10) }) it("ignores discount if expired", async () => { carts.discountCart.discounts.push(discounts.expiredDiscount) res = await totalsService.getDiscountTotal(carts.discountCart) - expect(res).toEqual(280) + expect(res).toEqual(0) }) - it("returns cart subtotal if no discounts are applied", async () => { + it("returns 0 if no discounts are applied", async () => { res = await totalsService.getDiscountTotal(carts.discountCart) - expect(res).toEqual(280) + expect(res).toEqual(0) }) it("returns 0 if no items are in cart", async () => { @@ -113,4 +147,219 @@ describe("TotalsService", () => { expect(res).toEqual(0) }) }) + + describe("getRefundTotal", () => { + let res + const totalsService = new TotalsService({ + productVariantService: ProductVariantServiceMock, + regionService: RegionServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + orders.orderToRefund.discounts = [] + }) + + it("calculates refund", async () => { + res = await totalsService.getRefundTotal(orders.orderToRefund, [ + { + _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, + }, + ]) + + expect(res).toEqual(1537.5) + }) + + it("calculates refund with total precentage discount", async () => { + orders.orderToRefund.discounts.push(discounts.total10Percent) + res = await totalsService.getRefundTotal(orders.orderToRefund, [ + { + _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, + }, + ]) + + expect(res).toEqual(1107) + }) + + it("calculates refund with total fixed discount", async () => { + orders.orderToRefund.discounts.push(discounts.total10Fixed) + res = await totalsService.getRefundTotal(orders.orderToRefund, [ + { + _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: 3, + }, + ]) + + expect(res).toEqual(359) + }) + + it("calculates refund with item fixed discount", async () => { + orders.orderToRefund.discounts.push(discounts.item2Fixed) + res = await totalsService.getRefundTotal(orders.orderToRefund, [ + { + _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("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 3, + }, + ]) + + expect(res).toEqual(363) + }) + + it("calculates refund with item percentage discount", async () => { + orders.orderToRefund.discounts.push(discounts.item10Percent) + res = await totalsService.getRefundTotal(orders.orderToRefund, [ + { + _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("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 3, + }, + ]) + + expect(res).toEqual(332.1) + }) + + it("throws if line items to return is not in order", async () => { + try { + await totalsService.getRefundTotal(orders.orderToRefund, [ + { + _id: IdMap.getId("notInOrder"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 3, + }, + ]) + } catch (error) { + expect(error.message).toEqual("Line items does not exist on order") + } + }) + }) + describe("getShippingTotal", () => { + let res + const totalsService = new TotalsService({ + productVariantService: ProductVariantServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calculates shipping", async () => { + res = await totalsService.getShippingTotal(orders.testOrder) + + expect(res).toEqual(110) + }) + }) + describe("getTaxTotal", () => { + let res + const totalsService = new TotalsService({ + productVariantService: ProductVariantServiceMock, + regionService: RegionServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + orders.orderToRefund.discounts = [] + }) + + it("calculates tax", async () => { + res = await totalsService.getTaxTotal(orders.testOrder) + + expect(res).toEqual(335) + }) + }) + + describe("getTotal", () => { + let res + const totalsService = new TotalsService({ + productVariantService: ProductVariantServiceMock, + regionService: RegionServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calculates total", async () => { + res = await totalsService.getTotal(orders.testOrder) + + expect(res).toEqual(1230 + 335 + 110) + }) + }) }) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index f6fd73a267..b6767d8c6a 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -725,7 +725,8 @@ class CartService extends BaseService { * also have additional details in the data field such as an id for a package * shop. * @param {string} cartId - the id of the cart to add shipping method to - * @param {ShippingMethod} method - the shipping method to add to the cart + * @param {string} optionId - id of shipping option to add as valid method + * @param {Object} data - the fulmillment data for the method * @return {Promise} the result of the update operation */ async addShippingMethod(cartId, optionId, data) { diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js new file mode 100644 index 0000000000..deb9a722e6 --- /dev/null +++ b/packages/medusa/src/services/order.js @@ -0,0 +1,474 @@ +import _ from "lodash" +import { Validator, MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" + +class OrderService extends BaseService { + constructor({ + orderModel, + paymentProviderService, + shippingProfileService, + fulfillmentProviderService, + lineItemService, + totalsService, + eventBusService, + }) { + super() + + /** @private @const {OrderModel} */ + this.orderModel_ = orderModel + + /** @private @const {PaymentProviderService} */ + this.paymentProviderService_ = paymentProviderService + + /** @private @const {ShippingProvileService} */ + this.shippingProfileService_ = shippingProfileService + + /** @private @const {FulfillmentProviderService} */ + this.fulfillmentProviderService_ = fulfillmentProviderService + + /** @private @const {LineItemService} */ + this.lineItemService_ = lineItemService + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + + /** @private @const {EventBus} */ + this.eventBus_ = eventBusService + } + + /** + * Used to validate order ids. Throws an error if the cast fails + * @param {string} rawId - the raw order id to validate. + * @return {string} the validated id + */ + validateId_(rawId) { + const schema = Validator.objectId() + const { value, error } = schema.validate(rawId) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "The order id could not be casted to an ObjectId" + ) + } + + return value + } + + /** + * Used to validate order addresses. Can be used to both + * validate shipping and billing address. + * @param {Address} address - the address to validate + * @return {Address} the validated address + */ + validateAddress_(address) { + const { value, error } = Validator.address().validate(address) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The address is not valid" + ) + } + + return value + } + + /** + * Used to validate email. + * @param {string} email - the email to vaildate + * @return {string} the validate email + */ + validateEmail_(email) { + const schema = Validator.string().email() + const { value, error } = schema.validate(email) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "The email is not valid" + ) + } + + return value + } + + async partitionItems_(shipping_methods, items) { + let updatedMethods = [] + // partition order items to their dedicated shipping method + await Promise.all( + shipping_methods.map(async method => { + const { profile_id } = method + const profile = await this.shippingProfileService_.retrieve(profile_id) + // for each method find the items in the order, that are associated + // with the profile on the current shipping method + if (shipping_methods.length === 1) { + method.items = items + } else { + method.items = items.filter(({ content }) => { + if (Array.isArray(content)) { + // we require bundles to have same shipping method, therefore: + return profile.products.includes(content[0].product._id) + } else { + return profile.products.includes(content.product._id) + } + }) + } + updatedMethods.push(method) + }) + ) + return updatedMethods + } + + /** + * Gets an order by id. + * @param {string} orderId - id of order to retrieve + * @return {Promise} the order document + */ + async retrieve(orderId) { + const validatedId = this.validateId_(orderId) + const order = await this.orderModel_ + .findOne({ _id: validatedId }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + + if (!order) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Order with ${orderId} was not found` + ) + } + return order + } + + /** + * Creates an order + * @param {object} order - the order to create + * @return {Promise} resolves to the creation result. + */ + async create(order) { + return this.orderModel_.create(order).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Updates an order. Metadata updates should + * use dedicated method, e.g. `setMetadata` etc. The function + * will throw errors if metadata updates are attempted. + * @param {string} orderId - the id of the order. Must be a string that + * can be casted to an ObjectId + * @param {object} update - an object with the update values. + * @return {Promise} resolves to the update result. + */ + async update(orderId, update) { + const order = await this.retrieve(orderId) + + if ( + (update.shipping_address || + update.billing_address || + update.payment_method || + update.items) && + (order.fulfillment_status !== "not_fulfilled" || + order.payment_status !== "awaiting" || + order.status !== "pending") + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't update shipping, billing, items and payment method when order is processed" + ) + } + + if (update.metadata) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Use setMetadata to update metadata fields" + ) + } + + if (update.status || update.fulfillment_status || update.payment_status) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't update order statuses. This will happen automatically. Use metadata in order for additional statuses" + ) + } + + const updateFields = { ...update } + + if (update.shipping_address) { + updateFields.shipping_address = this.validateAddress_( + update.shipping_address + ) + } + + if (update.billing_address) { + updateFields.billing_address = this.validateAddress_( + update.billing_address + ) + } + + if (update.items) { + updateFields.items = update.items.map(item => + this.lineItemService_.validate(item) + ) + } + + return this.orderModel_ + .updateOne( + { _id: order._id }, + { $set: updateFields }, + { runValidators: true } + ) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Cancels an order. + * Throws if fulfillment process has been initiated. + * Throws if payment process has been initiated. + * @param {string} orderId - id of order to cancel. + * @return {Promise} result of the update operation. + */ + async cancel(orderId) { + const order = await this.retrieve(orderId) + + if (order.fulfillment_status !== "not_fulfilled") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't cancel a fulfilled order" + ) + } + + if (order.payment_status !== "awaiting") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't cancel an order with payment processed" + ) + } + + // TODO: cancel payment method + + return this.orderModel_.updateOne( + { + _id: orderId, + }, + { + $set: { status: "cancelled" }, + } + ) + } + + /** + * Captures payment for an order. + * @param {string} orderId - id of order to capture payment for. + * @return {Promise} result of the update operation. + */ + async capturePayment(orderId) { + const order = await this.retrieve(orderId) + + if (order.payment_status !== "awaiting") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Payment already captured" + ) + } + + // prepare update object + const updateFields = { payment_status: "captured" } + const completed = order.fulfillment_status !== "not_fulfilled" + if (completed) { + updateFields.status = "completed" + } + + const { provider_id, data } = order.payment_method + const paymentProvider = await this.paymentProviderService_.retrieveProvider( + provider_id + ) + + await paymentProvider.capturePayment(data) + + return this.orderModel_.updateOne( + { + _id: orderId, + }, + { + $set: updateFields, + } + ) + } + + /** + * Creates fulfillments for an order. + * In a situation where the order has more than one shipping method, + * we need to partition the order items, such that they can be sent + * to their respective fulfillment provider. + * @param {string} orderId - id of order to cancel. + * @return {Promise} result of the update operation. + */ + async createFulfillment(orderId) { + const order = await this.retrieve(orderId) + + if (order.fulfillment_status !== "not_fulfilled") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Order is already fulfilled" + ) + } + + const { shipping_methods, items } = order + + // prepare update object + const updateFields = { fulfillment_status: "fulfilled" } + const completed = order.payment_status !== "awaiting" + if (completed) { + updateFields.status = "completed" + } + + // partition order items to their dedicated shipping method + order.shipping_methods = await this.partitionItems_(shipping_methods, items) + + await Promise.all( + order.shipping_methods.map(method => { + const provider = this.fulfillmentProviderService_.retrieveProvider( + method.provider_id + ) + provider.createOrder(method.data, method.items) + }) + ) + + return this.orderModel_.updateOne( + { + _id: orderId, + }, + { + $set: updateFields, + } + ) + } + + /** + * Return either the entire or part of an order. + * @param {string} orderId - the order to return. + * @param {string[]} lineItems - the line items to return + * @return {Promise} the result of the update operation + */ + async return(orderId, lineItems) { + const order = await this.retrieve(orderId) + + if ( + order.fulfillment_status === "not_fulfilled" || + order.fulfillment_status === "returned" + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't return an unfulfilled or already returned order" + ) + } + + if (order.payment_status !== "captured") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't return an order with payment unprocessed" + ) + } + + const { provider_id, data } = order.payment_method + const paymentProvider = this.paymentProviderService_.retrieveProvider( + provider_id + ) + + const amount = this.totalsService_.getRefundTotal(order, lineItems) + await paymentProvider.refundPayment(data, amount) + + lineItems.map(item => { + const returnedItem = order.items.find(({ _id }) => _id === item._id) + if (returnedItem) { + returnedItem.returned_quantity = item.quantity + } + }) + + const fullReturn = order.items.every( + item => item.quantity === item.returned_quantity + ) + + return this.orderModel_.updateOne( + { + _id: orderId, + }, + { + $set: { + items: order.items, + fulfillment_status: fullReturn ? "returned" : "partially_fulfilled", + }, + } + ) + } + + /** + * Archives an order. It only alloved, if the order has been fulfilled + * and payment has been captured. + * @param {string} orderId - the order to archive + * @return {Promise} the result of the update operation + */ + async archive(orderId) { + const order = await this.retrieve(orderId) + + if (order.status !== ("completed" || "refunded")) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't archive an unprocessed order" + ) + } + + return this.orderModel_.updateOne( + { + _id: orderId, + }, + { + $set: { status: "archived" }, + } + ) + } + + /** + * Decorates an order. + * @param {Order} order - the order to decorate. + * @param {string[]} fields - the fields to include. + * @param {string[]} expandFields - fields to expand. + * @return {Order} return the decorated order. + */ + async decorate(order, fields, expandFields = []) { + const requiredFields = ["_id", "metadata"] + const decorated = _.pick(order, fields.concat(requiredFields)) + return decorated + } + + /** + * Dedicated method to set metadata for an order. + * To ensure that plugins does not overwrite each + * others metadata fields, setMetadata is provided. + * @param {string} orderId - the order to decorate. + * @param {string} key - key for metadata field + * @param {string} value - value for metadata field. + * @return {Promise} resolves to the updated result. + */ + setMetadata(orderId, key, value) { + const validatedId = this.validateId_(orderId) + + if (typeof key !== "string") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Key type is invalid. Metadata keys must be strings" + ) + } + + const keyPath = `metadata.${key}` + return this.orderModel_ + .updateOne({ _id: validatedId }, { $set: { [keyPath]: value } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } +} + +export default OrderService diff --git a/packages/medusa/src/services/totals.js b/packages/medusa/src/services/totals.js index 79279f18a0..1e039f4405 100644 --- a/packages/medusa/src/services/totals.js +++ b/packages/medusa/src/services/totals.js @@ -1,28 +1,47 @@ import _ from "lodash" import { BaseService } from "medusa-interfaces" +import { MedusaError } from "medusa-core-utils" /** * A service that calculates total and subtotals for orders, carts etc.. * @implements BaseService */ class TotalsService extends BaseService { - constructor({ productVariantService }) { + constructor({ productVariantService, regionService }) { super() /** @private @const {ProductVariantService} */ this.productVariantService_ = productVariantService + + /** @private @const {RegionService} */ + this.regionService_ = regionService } + /** - * Calculates subtotal of a given cart - * @param {Cart} Cart - the cart to calculate subtotal for + * Calculates subtotal of a given cart or order. + * @param {Cart || Order} object - cart or order to calculate subtotal for * @return {int} the calculated subtotal */ - getSubtotal(cart) { + async getTotal(object) { + const subtotal = this.getSubtotal(object) + const taxTotal = await this.getTaxTotal(object) + const discountTotal = await this.getDiscountTotal(object) + const shippingTotal = this.getShippingTotal(object) + + return subtotal + taxTotal + shippingTotal - discountTotal + } + + /** + * Calculates subtotal of a given cart or order. + * @param {Cart || Order} object - cart or order to calculate subtotal for + * @return {int} the calculated subtotal + */ + getSubtotal(object) { let subtotal = 0 - if (!cart.items) { + if (!object.items) { return subtotal } - cart.items.map(item => { + object.items.map(item => { if (Array.isArray(item.content)) { const temp = _.sumBy(item.content, c => c.unit_price * c.quantity) subtotal += temp * item.quantity @@ -34,6 +53,137 @@ class TotalsService extends BaseService { return subtotal } + /** + * Calculates shipping total + * @param {Cart | Object} object - cart or order to calculate subtotal for + * @return {int} shipping total + */ + getShippingTotal(order) { + const { shipping_methods } = order + return shipping_methods.reduce((acc, next) => { + return acc + next.price + }, 0) + } + + /** + * Calculates tax total + * Currently based on the Danish tax system + * @param {Cart | Object} object - cart or order to calculate subtotal for + * @return {int} tax total + */ + async getTaxTotal(object) { + const subtotal = this.getSubtotal(object) + const shippingTotal = this.getShippingTotal(object) + const region = await this.regionService_.retrieve(object.region) + const { tax_rate } = region + return (subtotal + shippingTotal) * tax_rate + } + + /** + * Calculates refund total of line items. + * If any of the items to return have been discounted, we need to + * apply the discount again before refunding them. + * @param {Order} order - cart or order to calculate subtotal for + * @param {[LineItem]} lineItems - + * @return {int} the calculated subtotal + */ + async getRefundTotal(order, lineItems) { + const discount = order.discounts.find( + ({ discount_rule }) => discount_rule.type !== "free_shipping" + ) + + if (_.differenceBy(lineItems, order.items, "_id").length !== 0) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Line items does not exist on order" + ) + } + + const subtotal = this.getSubtotal({ items: lineItems }) + + const region = await this.regionService_.retrieve(order.region) + + // if nothing is discounted, return the subtotal of line items + if (!discount) { + return subtotal * (1 + region.tax_rate) + } + + const { value, type, allocation } = discount.discount_rule + + if (type === "percentage" && allocation === "total") { + const discountTotal = (subtotal / 100) * value + return subtotal - discountTotal + } + + if (type === "fixed" && allocation === "total") { + return subtotal - value + } + + if (type === "percentage" && allocation === "item") { + // Find discounted items + const itemPercentageDiscounts = await this.getAllocationItemDiscounts( + discount, + { items: lineItems }, + "percentage" + ) + + // Find discount total by taking each discounted item, reducing it by + // its discount value. Then summing all those items together. + const discountRefundTotal = _.sumBy( + itemPercentageDiscounts, + d => d.lineItem.content.unit_price * d.lineItem.quantity - d.amount + ) + + // Find the items that weren't discounted + const notDiscountedItems = _.differenceBy( + lineItems, + Array.from(itemPercentageDiscounts, el => el.lineItem), + "_id" + ) + + // If all items were discounted, we return the total of the discounted + // items + if (!notDiscountedItems) { + return discountRefundTotal + } + + // Otherwise, we find the total those not discounted + const notDiscRefundTotal = this.getSubtotal({ items: notDiscountedItems }) + + // Finally, return the sum of discounted and not discounted items + return notDiscRefundTotal + discountRefundTotal + } + + // See immediate `if`-statement above for a elaboration on the following + // calculations. This time with fixed discount type. + if (type === "fixed" && allocation === "item") { + const itemPercentageDiscounts = await this.getAllocationItemDiscounts( + discount, + { items: lineItems }, + "fixed" + ) + + const discountRefundTotal = _.sumBy( + itemPercentageDiscounts, + d => d.lineItem.content.unit_price * d.lineItem.quantity - d.amount + ) + + const notDiscountedItems = _.differenceBy( + lineItems, + Array.from(itemPercentageDiscounts, el => el.lineItem), + "_id" + ) + + if (!notDiscountedItems) { + return notDiscRefundTotal + } + + const notDiscRefundTotal = this.getSubtotal({ items: notDiscountedItems }) + + return notDiscRefundTotal + discountRefundTotal + } + } + /** * Calculates either fixed or percentage discount of a variant * @param {string} lineItem - id of line item @@ -49,13 +199,16 @@ class TotalsService extends BaseService { return { lineItem, variant, - amount: (variantPrice / 100) * value, + amount: ((variantPrice * lineItem.quantity) / 100) * value, } } else { return { lineItem, variant, - amount: value >= variantPrice ? variantPrice : value, + amount: + value >= variantPrice * lineItem.quantity + ? variantPrice * lineItem.quantity + : value * lineItem.quantity, } } } @@ -75,16 +228,16 @@ class TotalsService extends BaseService { const discounts = [] for (const item of cart.items) { if (discount.discount_rule.valid_for.length > 0) { - discount.discount_rule.valid_for.map(v => { + discount.discount_rule.valid_for.map(variant => { // Discounts do not apply to bundles, hence: if (Array.isArray(item.content)) { return discounts } else { - if (item.content.variant._id === v) { + if (item.content.variant._id === variant) { discounts.push( this.calculateDiscount_( - item._id, - v, + item, + variant, item.content.unit_price, discount.discount_rule.value, discount.discount_rule.type @@ -99,17 +252,16 @@ class TotalsService extends BaseService { } /** - * Calculates discount total of a cart using the discounts provided in the - * cart.discounts array. This will be subtracted from the cart subtotal, - * which is returned from the function. + * Calculates the total discount amount for each of the different supported + * discount types. If discounts aren't present or invalid returns 0. * @param {Cart} Cart - the cart to calculate discounts for - * @return {int} the subtotal after discounts are applied + * @return {int} the total discounts amount */ async getDiscountTotal(cart) { let subtotal = this.getSubtotal(cart) if (!cart.discounts) { - return subtotal + return 0 } // filter out invalid discounts @@ -135,14 +287,13 @@ class TotalsService extends BaseService { ) if (!discount) { - return subtotal + return 0 } const { type, allocation, value } = discount.discount_rule if (type === "percentage" && allocation === "total") { - subtotal -= (subtotal / 100) * value - return subtotal + return (subtotal / 100) * value } if (type === "percentage" && allocation === "item") { @@ -152,13 +303,11 @@ class TotalsService extends BaseService { "percentage" ) const totalDiscount = _.sumBy(itemPercentageDiscounts, d => d.amount) - subtotal -= totalDiscount - return subtotal + return totalDiscount } if (type === "fixed" && allocation === "total") { - subtotal -= value - return subtotal + return value } if (type === "fixed" && allocation === "item") { @@ -168,11 +317,10 @@ class TotalsService extends BaseService { "fixed" ) const totalDiscount = _.sumBy(itemFixedDiscounts, d => d.amount) - subtotal -= totalDiscount - return subtotal + return totalDiscount } - return subtotal + return 0 } }