From c1e821d9d4d33756c7309e5cf110d7aa9b67297d Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 14 Oct 2020 17:58:23 +0200 Subject: [PATCH] feat: return shipping and flow (#125) Adds support for return shipping methods and changes returns to have a request/receive flow. Store operators should now first request a return, noting the line items that will be returned. When the inventory is received the return will be registered triggering the refund. Return shipping methods can now be created for all regions. --- docs/services/OrderService.md | 20 + .../src/services/webshipper-fulfillment.js | 125 ++++ .../src/utils/webshipper.js | 7 + .../src/fulfillment-service.js | 31 + packages/medusa/package.json | 4 +- .../admin/orders/__tests__/return-order.js | 55 +- .../src/api/routes/admin/orders/index.js | 61 +- .../{return-order.js => receive-return.js} | 15 +- .../api/routes/admin/orders/request-return.js | 75 +++ .../__tests__/create-shipping-option.js | 3 + .../create-shipping-option.js | 1 + .../shipping-options/list-shipping-options.js | 2 +- .../medusa/src/models/__mocks__/document.js | 15 + packages/medusa/src/models/__mocks__/order.js | 163 ++++++ packages/medusa/src/models/document.js | 16 + packages/medusa/src/models/order.js | 1 + .../medusa/src/models/schemas/fulfillment.js | 1 + .../src/models/schemas/return-line-item.js | 23 + packages/medusa/src/models/schemas/return.js | 11 +- packages/medusa/src/models/shipping-option.js | 1 + .../medusa/src/services/__mocks__/document.js | 21 + .../__mocks__/fulfillment-provider.js | 33 ++ .../medusa/src/services/__mocks__/order.js | 8 +- .../src/services/__mocks__/shipping-option.js | 18 + .../medusa/src/services/__tests__/cart.js | 2 + .../medusa/src/services/__tests__/document.js | 24 + .../medusa/src/services/__tests__/order.js | 486 ++++++++++++++-- packages/medusa/src/services/cart.js | 1 + packages/medusa/src/services/document.js | 138 +++++ packages/medusa/src/services/order.js | 549 ++++++++++++++---- .../medusa/src/services/shipping-option.js | 43 +- 31 files changed, 1745 insertions(+), 208 deletions(-) create mode 100644 docs/services/OrderService.md rename packages/medusa/src/api/routes/admin/orders/{return-order.js => receive-return.js} (70%) create mode 100644 packages/medusa/src/api/routes/admin/orders/request-return.js create mode 100644 packages/medusa/src/models/__mocks__/document.js create mode 100644 packages/medusa/src/models/document.js create mode 100644 packages/medusa/src/models/schemas/return-line-item.js create mode 100644 packages/medusa/src/services/__mocks__/document.js create mode 100644 packages/medusa/src/services/__tests__/document.js create mode 100644 packages/medusa/src/services/document.js diff --git a/docs/services/OrderService.md b/docs/services/OrderService.md new file mode 100644 index 0000000000..b0ab513ecb --- /dev/null +++ b/docs/services/OrderService.md @@ -0,0 +1,20 @@ +## Returns + +Returns refer to the situation when a merchant takes back previously purchased items from a customer. A return will usually result in a refund to the customer, with an amount corresponding to the amount received from the customer at the time of purchase. + +A usual return flow follows the steps below: + +1. The customer requests a return - noting the items that they will be sending back. +2. The merchant provides the customer with a return label, that will be used on the package that is being sent back. +3. The merchant receives the package at their warehouse, and registers the return as being received. +4. The merchant refunds the money to the customer, taking any potential return shipping requests into account. + +A different flow that is less common follows the steps: + +1. The customer arranges a shipment themselves. +2. The merchant receives the package at their warehouse, and registers the return as being received. +3. The merchant refunds the money to the customer, taking any potential return shipping requests into account. + +In Medusa Admin return shipping options are created in the same way that outgoing shipping options are created. Each return shipping option is associated with a region giving you the flexibility to price returns differently depending on the region the order has been placed in. Returns are not required to have shipping methods as it may be the case that return is arranged independently of a fulfillment provider. + +To create a return in Medusa Admin the store operator finds the original order and clicks "Create Return", the store operator then selects the items to be returned along with a shipping option, once the return is created the fulfillment provider takes care of providing the necessary documentation for the return; this can also be viewed in Medusa Admin. diff --git a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js index 02c4330289..14a8d97dee 100644 --- a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js +++ b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js @@ -33,6 +33,7 @@ class WebshipperFulfillmentService extends FulfillmentService { name: r.attributes.name, require_drop_point: r.attributes.require_drop_point, carrier_id: r.attributes.carrier_id, + is_return: r.attributes.is_return, })) } @@ -63,6 +64,106 @@ class WebshipperFulfillmentService extends FulfillmentService { // Calculate prices } + /** + * Creates a return shipment in webshipper using the given method data, and + * return lines. + */ + async createReturn(methodData, returnLines, fromOrder) { + const relationships = { + shipping_rate: { + data: { + type: "shipping_rates", + id: methodData.webshipper_id, + }, + }, + } + + const existing = fromOrder.metadata.webshipper_order_id + if (existing) { + relationships.order = { + data: { + type: "orders", + id: existing, + }, + } + } + + let docs = [] + if (this.invoiceGenerator_) { + const base64Invoice = await this.invoiceGenerator_.createReturnInvoice( + fromOrder, + returnLines + ) + docs.push({ + document_size: "A4", + document_format: "PDF", + base64: base64Invoice, + document_type: "invoice", + }) + } + + const { shipping_address } = fromOrder + const returnShipment = { + type: "shipments", + attributes: { + reference: `R${fromOrder.display_id}-${fromOrder.returns.length + 1}`, + ext_ref: `${fromOrder._id}.${fromOrder.returns.length}`, + is_return: true, + included_documents: docs, + sender_address: { + att_contact: `${shipping_address.first_name} ${shipping_address.last_name}`, + address_1: shipping_address.address_1, + address_2: shipping_address.address_2, + zip: shipping_address.postal_code, + city: shipping_address.city, + country_code: shipping_address.country_code, + state: shipping_address.province, + phone: shipping_address.phone, + email: fromOrder.email, + }, + delivery_address: this.options_.return_address, + }, + relationships, + } + + return this.client_.shipments + .create(returnShipment) + .then((result) => { + return result.data + }) + .catch((err) => { + this.logger_.warn(err.response) + throw err + }) + } + + async getReturnDocuments(data) { + const shipment = await this.client_.shipments.retrieve(data.id) + const labels = await this.retrieveRelationship( + shipment.data.relationships.labels + ).then((res) => res.data) + const docs = await this.retrieveRelationship( + shipment.data.relationships.documents + ).then((res) => res.data) + const toReturn = [] + for (const d of labels) { + toReturn.push({ + name: "Return label", + base_64: d.attributes.base64, + type: "pdf", + }) + } + for (const d of docs) { + toReturn.push({ + name: d.attributes.document_type, + base_64: d.attributes.base64, + type: "pdf", + }) + } + + return toReturn + } + async createOrder(methodData, fulfillmentItems, fromOrder) { const existing = fromOrder.metadata && fromOrder.metadata.webshipper_order_id @@ -205,6 +306,30 @@ class WebshipperFulfillmentService extends FulfillmentService { } } + /** + * This plugin doesn't support shipment documents. + */ + async getShipmentDocuments() { + return [] + } + + /** + * Retrieves the documents associated with an order. + * @return {Promise>} an array of document objects to store in the + * database. + */ + async getFulfillmentDocuments(data) { + const order = await this.client_.orders.retrieve(data.id) + const docs = await this.retrieveRelationship( + order.data.relationships.documents + ).then((res) => res.data) + return docs.map((d) => ({ + name: d.attributes.document_type, + base_64: d.attributes.base64, + type: "pdf", + })) + } + async retrieveDropPoints(id, zip, countryCode, address1) { const points = await this.client_ .request({ diff --git a/packages/medusa-fulfillment-webshipper/src/utils/webshipper.js b/packages/medusa-fulfillment-webshipper/src/utils/webshipper.js index 038803586b..af4b0e5212 100644 --- a/packages/medusa-fulfillment-webshipper/src/utils/webshipper.js +++ b/packages/medusa-fulfillment-webshipper/src/utils/webshipper.js @@ -95,6 +95,13 @@ class Webshipper { buildShipmentEndpoints_ = () => { return { + retrieve: async (id) => { + const path = `/v2/shipments/${id}` + return this.client_({ + method: "GET", + url: path, + }).then(({ data }) => data) + }, create: async (data) => { const path = `/v2/shipments` return this.client_({ diff --git a/packages/medusa-interfaces/src/fulfillment-service.js b/packages/medusa-interfaces/src/fulfillment-service.js index 892a0d055c..c5d3f2bf51 100644 --- a/packages/medusa-interfaces/src/fulfillment-service.js +++ b/packages/medusa-interfaces/src/fulfillment-service.js @@ -63,6 +63,37 @@ class BaseFulfillmentService extends BaseService { createOrder() { throw Error("createOrder must be overridden by the child class") } + + /** + * Used to retrieve documents associated with a fulfillment. + * Will default to returning no documents. + */ + getFulfillmentDocuments(data) { + return [] + } + + /** + * Used to create a return order. Should return the data necessary for future + * operations on the return; in particular the data may be used to receive + * documents attached to the return. + */ + createReturn(fromData) { + throw Error("createReturn must be overridden by the child class") + } + + /** + * Used to retrieve documents related to a return order. + */ + getReturnDocuments(data) { + return [] + } + + /** + * Used to retrieve documents related to a shipment. + */ + getShipmentDocuments(data) { + return [] + } } export default BaseFulfillmentService diff --git a/packages/medusa/package.json b/packages/medusa/package.json index c9b4d965a5..bd52d7fa86 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -1,6 +1,6 @@ { "name": "@medusajs/medusa", - "version": "1.0.25", + "version": "1.0.26-alpha.6+b54e287", "description": "E-commerce for JAMstack", "main": "dist/app.js", "repository": { @@ -75,5 +75,5 @@ "scrypt-kdf": "^2.0.1", "winston": "^3.2.1" }, - "gitHead": "3bd91f65304ed1d31c41b85d5c87123450e0542e" + "gitHead": "b54e28769a423c9285a1119535cfa1590d08a559" } 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 index 57999bd374..040f8826a8 100644 --- a/packages/medusa/src/api/routes/admin/orders/__tests__/return-order.js +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/return-order.js @@ -7,6 +7,7 @@ describe("POST /admin/orders/:id/return", () => { let subject beforeAll(async () => { + jest.clearAllMocks() subject = await request( "POST", `/admin/orders/${IdMap.getId("test-order")}/return`, @@ -33,8 +34,8 @@ describe("POST /admin/orders/:id/return", () => { }) it("calls OrderService return", () => { - expect(OrderServiceMock.return).toHaveBeenCalledTimes(1) - expect(OrderServiceMock.return).toHaveBeenCalledWith( + expect(OrderServiceMock.requestReturn).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.requestReturn).toHaveBeenCalledWith( IdMap.getId("test-order"), [ { @@ -42,7 +43,55 @@ describe("POST /admin/orders/:id/return", () => { quantity: 10, }, ], - undefined + undefined, // no shipping method + undefined // no refund amount + ) + }) + }) + + describe("defaults to 0 on negative refund amount", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request( + "POST", + `/admin/orders/${IdMap.getId("test-order")}/return`, + { + payload: { + items: [ + { + item_id: IdMap.getId("existingLine"), + quantity: 10, + }, + ], + refund: -1, + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls OrderService return", () => { + expect(OrderServiceMock.requestReturn).toHaveBeenCalledTimes(1) + expect(OrderServiceMock.requestReturn).toHaveBeenCalledWith( + IdMap.getId("test-order"), + [ + { + item_id: IdMap.getId("existingLine"), + quantity: 10, + }, + ], + undefined, // no shipping method + 0 ) }) }) diff --git a/packages/medusa/src/api/routes/admin/orders/index.js b/packages/medusa/src/api/routes/admin/orders/index.js index 5fae795122..9be363ca9f 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.js +++ b/packages/medusa/src/api/routes/admin/orders/index.js @@ -6,35 +6,90 @@ const route = Router() export default app => { app.use("/orders", route) + /** + * List orders + */ route.get("/", middlewares.wrap(require("./list-orders").default)) + + /** + * Get an order + */ route.get("/:id", middlewares.wrap(require("./get-order").default)) + /** + * Create a new order + */ route.post("/", middlewares.wrap(require("./create-order").default)) + + /** + * Update an order + */ route.post("/:id", middlewares.wrap(require("./update-order").default)) + + /** + * Mark an order as completed + */ route.post( "/:id/complete", middlewares.wrap(require("./complete-order").default) ) + /** + * Refund an amount to the customer's card. + */ route.post( "/:id/refund", middlewares.wrap(require("./refund-payment").default) ) + + /** + * Capture the authorized amount on the customer's card. + */ route.post( "/:id/capture", middlewares.wrap(require("./capture-payment").default) ) + + /** + * Create a fulfillment. + */ + route.post( + "/:id/fulfillment", + middlewares.wrap(require("./create-fulfillment").default) + ) + + /** + * Create a shipment. + */ route.post( "/:id/shipment", middlewares.wrap(require("./create-shipment").default) ) + /** + * Request a return. + */ route.post( - "/:id/fulfillment", - middlewares.wrap(require("./create-fulfillment").default) + "/:id/return", + middlewares.wrap(require("./request-return").default) ) - route.post("/:id/return", middlewares.wrap(require("./return-order").default)) + + /** + * Register a requested return + */ + route.post( + "/:id/return/:return_id/receive", + middlewares.wrap(require("./receive-return").default) + ) + + /** + * Cancel an order. + */ route.post("/:id/cancel", middlewares.wrap(require("./cancel-order").default)) + + /** + * Archive an order. + */ route.post( "/:id/archive", middlewares.wrap(require("./archive-order").default) diff --git a/packages/medusa/src/api/routes/admin/orders/return-order.js b/packages/medusa/src/api/routes/admin/orders/receive-return.js similarity index 70% rename from packages/medusa/src/api/routes/admin/orders/return-order.js rename to packages/medusa/src/api/routes/admin/orders/receive-return.js index e55a6f174a..3b936a5da3 100644 --- a/packages/medusa/src/api/routes/admin/orders/return-order.js +++ b/packages/medusa/src/api/routes/admin/orders/receive-return.js @@ -1,7 +1,7 @@ import { MedusaError, Validator } from "medusa-core-utils" export default async (req, res) => { - const { id } = req.params + const { id, return_id } = req.params const schema = Validator.object().keys({ items: Validator.array() @@ -20,7 +20,18 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") - let order = await orderService.return(id, value.items, value.refund) + + let refundAmount = value.refund + if (typeof value.refund !== "undefined" && value.refund < 0) { + refundAmount = 0 + } + let order = await orderService.return( + id, + return_id, + value.items, + refundAmount, + true + ) order = await orderService.decorate(order, [], ["region"]) res.status(200).json({ order }) diff --git a/packages/medusa/src/api/routes/admin/orders/request-return.js b/packages/medusa/src/api/routes/admin/orders/request-return.js new file mode 100644 index 0000000000..cc173cecbf --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/request-return.js @@ -0,0 +1,75 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + items: Validator.array() + .items({ + item_id: Validator.string().required(), + quantity: Validator.number().required(), + }) + .required(), + shipping_method: Validator.string().optional(), + shipping_price: Validator.number().optional(), + receive_now: Validator.boolean().default(false), + refund: Validator.number().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") + + let oldOrder + let existingReturns = [] + if (value.receive_now) { + oldOrder = await orderService.retrieve(id) + existingReturns = oldOrder.returns.map(r => r._id) + } + + let shippingMethod + if (value.shipping_method) { + shippingMethod = { + id: value.shipping_method, + price: value.shipping_price, + } + } + + let refundAmount = value.refund + if (typeof value.refund !== "undefined" && value.refund < 0) { + refundAmount = 0 + } + let order = await orderService.requestReturn( + id, + value.items, + shippingMethod, + refundAmount + ) + + /** + * If we are ready to receive immediately, we find the newly created return + * and register it as received. + */ + if (value.receive_now) { + const newReturn = order.returns.find( + r => !existingReturns.includes(r._id) + ) + order = await orderService.return( + id, + newReturn._id, + value.items, + value.refund + ) + } + + order = await orderService.decorate(order, [], ["region"]) + + res.status(200).json({ order }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/shipping-options/__tests__/create-shipping-option.js b/packages/medusa/src/api/routes/admin/shipping-options/__tests__/create-shipping-option.js index 86d5f1eb6e..ffcfe15e74 100644 --- a/packages/medusa/src/api/routes/admin/shipping-options/__tests__/create-shipping-option.js +++ b/packages/medusa/src/api/routes/admin/shipping-options/__tests__/create-shipping-option.js @@ -7,6 +7,7 @@ describe("POST /admin/shipping-options", () => { let subject beforeAll(async () => { + jest.clearAllMocks() subject = await request("POST", "/admin/shipping-options", { payload: { name: "Test option", @@ -39,6 +40,7 @@ describe("POST /admin/shipping-options", () => { it("calls service create", () => { expect(ShippingOptionServiceMock.create).toHaveBeenCalledTimes(1) expect(ShippingOptionServiceMock.create).toHaveBeenCalledWith({ + is_return: false, name: "Test option", region_id: "testregion", provider_id: "test_provider", @@ -62,6 +64,7 @@ describe("POST /admin/shipping-options", () => { let subject beforeAll(async () => { + jest.clearAllMocks() subject = await request("POST", "/admin/shipping-options", { payload: { price: { diff --git a/packages/medusa/src/api/routes/admin/shipping-options/create-shipping-option.js b/packages/medusa/src/api/routes/admin/shipping-options/create-shipping-option.js index 1261c6afbb..a5b3f7efba 100644 --- a/packages/medusa/src/api/routes/admin/shipping-options/create-shipping-option.js +++ b/packages/medusa/src/api/routes/admin/shipping-options/create-shipping-option.js @@ -21,6 +21,7 @@ export default async (req, res) => { }) ) .optional(), + is_return: Validator.boolean().default(false), }) const { value, error } = schema.validate(req.body) diff --git a/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.js b/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.js index 51f1e455ba..fdaafe1baf 100644 --- a/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.js +++ b/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.js @@ -2,7 +2,7 @@ import _ from "lodash" export default async (req, res) => { try { - const query = _.pick(req.query, ["region_id", "region_id[]"]) + const query = _.pick(req.query, ["region_id", "region_id[]", "is_return"]) const optionService = req.scope.resolve("shippingOptionService") const data = await optionService.list(query) diff --git a/packages/medusa/src/models/__mocks__/document.js b/packages/medusa/src/models/__mocks__/document.js new file mode 100644 index 0000000000..b020ee5b57 --- /dev/null +++ b/packages/medusa/src/models/__mocks__/document.js @@ -0,0 +1,15 @@ +import { IdMap } from "medusa-test-utils" + +export const documents = [ + { + _id: IdMap.getId("doc"), + name: "test doc", + base_64: "verylongstring", + }, +] + +export const DocumentModelMock = { + findOne: jest.fn().mockImplementation(query => { + return Promise.resolve(documents[0]) + }), +} diff --git a/packages/medusa/src/models/__mocks__/order.js b/packages/medusa/src/models/__mocks__/order.js index 7ee31b0525..113b2586c0 100644 --- a/packages/medusa/src/models/__mocks__/order.js +++ b/packages/medusa/src/models/__mocks__/order.js @@ -59,6 +59,7 @@ export const orders = { ], fulfillments: [ { + _id: IdMap.getId("fulfillment"), provider_id: "default_provider", data: {}, }, @@ -137,6 +138,114 @@ export const orders = { payment_status: "captured", status: "completed", }, + returnedOrder: { + _id: IdMap.getId("returned-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", + returned_quantity: 0, + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + region_id: IdMap.getId("region-france"), + customer_id: IdMap.getId("test-customer"), + payment_method: { + provider_id: "default_provider", + data: { + hi: "hi", + }, + }, + returns: [ + { + _id: IdMap.getId("return"), + status: "requested", + shipping_method: { + _id: IdMap.getId("return-shipping"), + is_return: true, + name: "Return Shipping", + region_id: IdMap.getId("region-france"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "return_shipment", + }, + price: 2, + provider_id: "default_provider", + }, + shipping_data: { + id: "return_shipment", + shipped: true, + }, + documents: ["doc1234"], + items: [ + { + item_id: IdMap.getId("existingLine"), + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("validId"), + }, + quantity: 1, + }, + is_requested: true, + quantity: 10, + }, + ], + refund_amount: 1228, + }, + ], + 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", @@ -192,6 +301,8 @@ export const orders = { quantity: 1, }, quantity: 10, + returned_quantity: 0, + metadata: {}, }, ], region_id: IdMap.getId("region-france"), @@ -199,6 +310,48 @@ export const orders = { payment_method: { provider_id: "default_provider", }, + returns: [ + { + _id: IdMap.getId("return"), + status: "requested", + shipping_method: { + _id: IdMap.getId("return-shipping"), + is_return: true, + name: "Return Shipping", + region_id: IdMap.getId("region-france"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "return_shipment", + }, + price: 2, + provider_id: "default_provider", + }, + shipping_data: { + id: "return_shipment", + shipped: true, + }, + documents: ["doc1234"], + items: [ + { + item_id: IdMap.getId("existingLine"), + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + is_requested: true, + quantity: 2, + metadata: {}, + }, + ], + refund_amount: 246, + }, + ], shipping_methods: [ { provider_id: "default_provider", @@ -220,6 +373,13 @@ export const orders = { export const OrderModelMock = { create: jest.fn().mockImplementation(data => Promise.resolve(data)), updateOne: jest.fn().mockImplementation((query, update) => { + if (query._id === IdMap.getId("returned-order")) { + return Promise.resolve(orders.returnedOrder) + } + if (query._id === IdMap.getId("order-refund")) { + orders.orderToRefund.payment_status = "captured" + return Promise.resolve(orders.orderToRefund) + } return Promise.resolve() }), deleteOne: jest.fn().mockReturnValue(Promise.resolve()), @@ -250,6 +410,9 @@ export const OrderModelMock = { if (query._id === IdMap.getId("processed-order")) { return Promise.resolve(orders.processedOrder) } + if (query._id === IdMap.getId("returned-order")) { + return Promise.resolve(orders.returnedOrder) + } if (query._id === IdMap.getId("order-refund")) { orders.orderToRefund.payment_status = "captured" return Promise.resolve(orders.orderToRefund) diff --git a/packages/medusa/src/models/document.js b/packages/medusa/src/models/document.js new file mode 100644 index 0000000000..2dc2356e02 --- /dev/null +++ b/packages/medusa/src/models/document.js @@ -0,0 +1,16 @@ +import mongoose from "mongoose" +import { BaseModel } from "medusa-interfaces" + +class DocumentModel extends BaseModel { + static modelName = "Document" + + static schema = { + base_64: { type: String, required: true }, + name: { type: String, required: true }, + type: { type: String, required: true }, + created: { type: String, default: Date.now }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, + } +} + +export default DocumentModel diff --git a/packages/medusa/src/models/order.js b/packages/medusa/src/models/order.js index e15211c9b3..d0ae816bcc 100644 --- a/packages/medusa/src/models/order.js +++ b/packages/medusa/src/models/order.js @@ -38,6 +38,7 @@ class OrderModel extends BaseModel { customer_id: { type: String }, payment_method: { type: PaymentMethodSchema, required: true }, shipping_methods: { type: [ShippingMethodSchema], required: true }, + documents: { type: [String], default: [] }, created: { type: String, default: Date.now }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } diff --git a/packages/medusa/src/models/schemas/fulfillment.js b/packages/medusa/src/models/schemas/fulfillment.js index 4d4bd079d0..327534e190 100644 --- a/packages/medusa/src/models/schemas/fulfillment.js +++ b/packages/medusa/src/models/schemas/fulfillment.js @@ -8,5 +8,6 @@ export default new mongoose.Schema({ tracking_numbers: { type: [String], default: [] }, shipped_at: { type: String }, is_canceled: { type: Boolean, default: false }, + documents: { type: [String], default: [] }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, }) diff --git a/packages/medusa/src/models/schemas/return-line-item.js b/packages/medusa/src/models/schemas/return-line-item.js new file mode 100644 index 0000000000..67b621fad9 --- /dev/null +++ b/packages/medusa/src/models/schemas/return-line-item.js @@ -0,0 +1,23 @@ +/******************************************************************************* + * + ******************************************************************************/ +import mongoose from "mongoose" + +/** + * @typedef ReturnLineItem + * @property {String} item_id + * @property {Object} content + * @property {Number} quantity + * @property {Boolean} is_requested + * @property {Boolean} is_registered + * @property {Object} metadata + */ +export default new mongoose.Schema({ + item_id: { type: String, required: true, unique: true }, + content: { type: mongoose.Schema.Types.Mixed, required: true }, + quantity: { type: Number, required: true }, + is_requested: { type: Boolean, required: true }, + requested_quantity: { type: Number }, + is_registered: { type: Boolean, default: false }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, +}) diff --git a/packages/medusa/src/models/schemas/return.js b/packages/medusa/src/models/schemas/return.js index 3447298a07..a261f1f58c 100644 --- a/packages/medusa/src/models/schemas/return.js +++ b/packages/medusa/src/models/schemas/return.js @@ -1,8 +1,15 @@ import mongoose from "mongoose" +import ReturnLineItemSchema from "./return-line-item" +import ShippingMethodSchema from "./shipping-method" export default new mongoose.Schema({ - created: { type: String, default: Date.now }, + status: { type: String, default: "requested" }, refund_amount: { type: Number, required: true }, - items: { type: [mongoose.Schema.Types.Mixed], required: true }, + items: { type: [ReturnLineItemSchema], required: true }, + shipping_method: { type: ShippingMethodSchema, default: {} }, + shipping_data: { type: mongoose.Schema.Types.Mixed, default: {} }, + documents: { type: [String], default: [] }, + received_at: { type: String }, + created: { type: String, default: Date.now }, metadata: { 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 d7b17834fc..c4d3c14d72 100644 --- a/packages/medusa/src/models/shipping-option.js +++ b/packages/medusa/src/models/shipping-option.js @@ -14,6 +14,7 @@ class ShippingOptionModel extends BaseModel { data: { type: mongoose.Schema.Types.Mixed, default: {} }, price: { type: ShippingOptionPrice, required: true }, requirements: { type: [ShippingOptionRequirement], default: [] }, + is_return: { type: Boolean, default: false }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } } diff --git a/packages/medusa/src/services/__mocks__/document.js b/packages/medusa/src/services/__mocks__/document.js new file mode 100644 index 0000000000..a86d14d0ef --- /dev/null +++ b/packages/medusa/src/services/__mocks__/document.js @@ -0,0 +1,21 @@ +import { IdMap } from "medusa-test-utils" + +export const DocumentServiceMock = { + create: jest.fn().mockImplementation(data => { + return Promise.resolve({ + _id: "doc1234", + }) + }), + retrieve: jest.fn().mockImplementation(data => { + return Promise.resolve(undefined) + }), + update: jest.fn().mockImplementation(data => { + return Promise.resolve() + }), +} + +const mock = jest.fn().mockImplementation(() => { + return DocumentServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/fulfillment-provider.js b/packages/medusa/src/services/__mocks__/fulfillment-provider.js index 999703410e..0c1b65fcbf 100644 --- a/packages/medusa/src/services/__mocks__/fulfillment-provider.js +++ b/packages/medusa/src/services/__mocks__/fulfillment-provider.js @@ -22,6 +22,39 @@ export const DefaultProviderMock = { createOrder: jest.fn().mockImplementation(data => { return Promise.resolve(data) }), + getFulfillmentDocuments: jest.fn().mockImplementation(() => { + return Promise.resolve([ + { + name: "Test", + type: "pdf", + base_64: "verylong", + }, + ]) + }), + createReturn: jest.fn().mockImplementation(data => { + return Promise.resolve({ + ...data, + shipped: true, + }) + }), + getReturnDocuments: jest.fn().mockImplementation(() => { + return Promise.resolve([ + { + name: "Test Return", + type: "pdf", + base_64: "verylong return", + }, + ]) + }), + getShipmentDocuments: jest.fn().mockImplementation(() => { + return Promise.resolve([ + { + name: "Test Shipment", + type: "pdf", + base_64: "verylong shipment", + }, + ]) + }), } export const FulfillmentProviderServiceMock = { diff --git a/packages/medusa/src/services/__mocks__/order.js b/packages/medusa/src/services/__mocks__/order.js index ea1f122ccb..8f5834e186 100644 --- a/packages/medusa/src/services/__mocks__/order.js +++ b/packages/medusa/src/services/__mocks__/order.js @@ -181,7 +181,13 @@ export const OrderServiceMock = { } return Promise.resolve(undefined) }), - return: jest.fn().mockImplementation(order => { + requestReturn: jest.fn().mockImplementation(order => { + if (order === IdMap.getId("test-order")) { + return Promise.resolve(orders.testOrder) + } + return Promise.resolve(undefined) + }), + receiveReturn: jest.fn().mockImplementation(order => { if (order === IdMap.getId("test-order")) { return Promise.resolve(orders.testOrder) } diff --git a/packages/medusa/src/services/__mocks__/shipping-option.js b/packages/medusa/src/services/__mocks__/shipping-option.js index 97b4e32d90..d20a20314c 100644 --- a/packages/medusa/src/services/__mocks__/shipping-option.js +++ b/packages/medusa/src/services/__mocks__/shipping-option.js @@ -1,6 +1,21 @@ import { IdMap } from "medusa-test-utils" export const shippingOptions = { + returnShipping: { + _id: IdMap.getId("return-shipping"), + is_return: true, + name: "Return Shipping", + region_id: IdMap.getId("region-france"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "return_shipment", + }, + price: { + type: "flat_rate", + amount: 20, + }, + provider_id: "default_provider", + }, freeShipping: { _id: IdMap.getId("freeShipping"), name: "Free Shipping", @@ -53,6 +68,9 @@ export const shippingOptions = { export const ShippingOptionServiceMock = { retrieve: jest.fn().mockImplementation(optionId => { + if (optionId === IdMap.getId("return-shipping")) { + return Promise.resolve(shippingOptions.returnShipping) + } if (optionId === IdMap.getId("shipping1")) { return Promise.resolve(shippingOptions.shipping1) } diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 74ac2b39b2..673624dc69 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -759,6 +759,7 @@ describe("CartService", () => { title: "merge line", description: "This is a new line", thumbnail: "test-img-yeah.com/thumb", + has_shipping: false, content: [ { unit_price: 10, @@ -788,6 +789,7 @@ describe("CartService", () => { title: "merge line", description: "This is a new line", thumbnail: "test-img-yeah.com/thumb", + has_shipping: false, content: { unit_price: 12, variant: { diff --git a/packages/medusa/src/services/__tests__/document.js b/packages/medusa/src/services/__tests__/document.js new file mode 100644 index 0000000000..94cbe9ad53 --- /dev/null +++ b/packages/medusa/src/services/__tests__/document.js @@ -0,0 +1,24 @@ +import DocumentService from "../document" +import { DocumentModelMock } from "../../models/__mocks__/document" +import { IdMap } from "medusa-test-utils" + +describe("DocumentService", () => { + describe("retrieve", () => { + const documentService = new DocumentService({ + documentModel: DocumentModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("retrieves a document", async () => { + await documentService.retrieve(IdMap.getId("doc")) + + expect(DocumentModelMock.findOne).toHaveBeenCalledTimes(1) + expect(DocumentModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("doc"), + }) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index 229d072e3b..e2be298282 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -12,6 +12,7 @@ import { DefaultProviderMock as FulfillmentProviderMock, } from "../__mocks__/fulfillment-provider" import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile" +import { ShippingOptionServiceMock } from "../__mocks__/shipping-option" import { TotalsServiceMock } from "../__mocks__/totals" import { RegionServiceMock } from "../__mocks__/region" import { CounterServiceMock } from "../__mocks__/counter" @@ -62,6 +63,7 @@ describe("OrderService", () => { currency_code: "eur", cart_id: carts.completeCart._id, tax_rate: 0.25, + metadata: {}, } delete order._id delete order.payment_sessions @@ -77,6 +79,7 @@ describe("OrderService", () => { const order = { ...carts.withGiftCard, + metadata: {}, items: [ { _id: IdMap.getId("existingLine"), @@ -352,6 +355,7 @@ describe("OrderService", () => { payment_status: "canceled", fulfillments: [ { + _id: IdMap.getId("fulfillment"), data: {}, is_canceled: true, provider_id: "default_provider", @@ -552,32 +556,70 @@ describe("OrderService", () => { }) it("calls order model functions", async () => { - await orderService.return(IdMap.getId("processed-order"), [ - { - item_id: IdMap.getId("existingLine"), - quantity: 10, - }, - ]) + await orderService.return( + IdMap.getId("returned-order"), + IdMap.getId("return"), + [ + { + item_id: IdMap.getId("existingLine"), + quantity: 10, + }, + ] + ) expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) expect(OrderModelMock.updateOne).toHaveBeenCalledWith( - { _id: IdMap.getId("processed-order") }, + { _id: IdMap.getId("returned-order") }, { $push: { refunds: { - amount: 1230, - }, - returns: { - items: [ - { - item_id: IdMap.getId("existingLine"), - quantity: 10, - }, - ], - refund_amount: 1230, + amount: 1228, }, }, $set: { + returns: [ + { + _id: IdMap.getId("return"), + status: "received", + documents: ["doc1234"], + shipping_method: { + _id: IdMap.getId("return-shipping"), + is_return: true, + name: "Return Shipping", + region_id: IdMap.getId("region-france"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "return_shipment", + }, + price: 2, + provider_id: "default_provider", + }, + shipping_data: { + id: "return_shipment", + shipped: true, + }, + items: [ + { + item_id: IdMap.getId("existingLine"), + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("validId"), + }, + quantity: 1, + }, + is_requested: true, + is_registered: true, + quantity: 10, + requested_quantity: 10, + }, + ], + refund_amount: 1228, + }, + ], items: [ { _id: IdMap.getId("existingLine"), @@ -607,13 +649,14 @@ describe("OrderService", () => { expect(DefaultProviderMock.refundPayment).toHaveBeenCalledTimes(1) expect(DefaultProviderMock.refundPayment).toHaveBeenCalledWith( { hi: "hi" }, - 1230 + 1228 ) }) it("return with custom refund", async () => { await orderService.return( - IdMap.getId("processed-order"), + IdMap.getId("returned-order"), + IdMap.getId("return"), [ { item_id: IdMap.getId("existingLine"), @@ -625,21 +668,12 @@ describe("OrderService", () => { expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) expect(OrderModelMock.updateOne).toHaveBeenCalledWith( - { _id: IdMap.getId("processed-order") }, + { _id: IdMap.getId("returned-order") }, { $push: { refunds: { amount: 102, }, - returns: { - items: [ - { - item_id: IdMap.getId("existingLine"), - quantity: 10, - }, - ], - refund_amount: 102, - }, }, $set: { items: [ @@ -663,6 +697,49 @@ describe("OrderService", () => { returned: true, }, ], + returns: [ + { + documents: ["doc1234"], + _id: IdMap.getId("return"), + status: "received", + shipping_method: { + _id: IdMap.getId("return-shipping"), + is_return: true, + name: "Return Shipping", + region_id: IdMap.getId("region-france"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "return_shipment", + }, + price: 2, + provider_id: "default_provider", + }, + shipping_data: { + id: "return_shipment", + shipped: true, + }, + items: [ + { + item_id: IdMap.getId("existingLine"), + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("validId"), + }, + quantity: 1, + }, + is_requested: true, + is_registered: true, + quantity: 10, + requested_quantity: 10, + }, + ], + refund_amount: 102, + }, + ], fulfillment_status: "returned", }, } @@ -676,12 +753,16 @@ describe("OrderService", () => { }) it("calls order model functions and sets partially_returned", async () => { - await orderService.return(IdMap.getId("order-refund"), [ - { - item_id: IdMap.getId("existingLine"), - quantity: 2, - }, - ]) + await orderService.return( + IdMap.getId("order-refund"), + IdMap.getId("return"), + [ + { + item_id: IdMap.getId("existingLine"), + quantity: 2, + }, + ] + ) expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) expect(OrderModelMock.updateOne).toHaveBeenCalledWith( @@ -689,19 +770,54 @@ describe("OrderService", () => { { $push: { refunds: { - amount: 0, - }, - returns: { - items: [ - { - item_id: IdMap.getId("existingLine"), - quantity: 2, - }, - ], - refund_amount: 0, + amount: 246, }, }, $set: { + returns: [ + { + _id: IdMap.getId("return"), + status: "received", + shipping_method: { + _id: IdMap.getId("return-shipping"), + is_return: true, + name: "Return Shipping", + region_id: IdMap.getId("region-france"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "return_shipment", + }, + price: 2, + provider_id: "default_provider", + }, + documents: ["doc1234"], + shipping_data: { + id: "return_shipment", + shipped: true, + }, + items: [ + { + item_id: IdMap.getId("existingLine"), + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + is_requested: true, + is_registered: true, + requested_quantity: 2, + quantity: 2, + metadata: {}, + }, + ], + refund_amount: 246, + }, + ], items: [ { _id: IdMap.getId("existingLine"), @@ -738,6 +854,8 @@ describe("OrderService", () => { quantity: 1, }, quantity: 10, + returned_quantity: 0, + metadata: {}, }, ], fulfillment_status: "partially_returned", @@ -746,23 +864,279 @@ describe("OrderService", () => { ) }) - 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("sets requires_action on additional items", async () => { + await orderService.return( + IdMap.getId("order-refund"), + IdMap.getId("return"), + [ + { + item_id: IdMap.getId("existingLine"), + quantity: 2, + }, + { + item_id: IdMap.getId("existingLine2"), + quantity: 2, + }, + ] + ) + + const originalReturn = orders.orderToRefund.returns[0] + const toSet = { + ...originalReturn, + status: "requires_action", + items: [ + ...originalReturn.items.map((i, index) => ({ + ...i, + requested_quantity: i.quantity, + is_requested: index === 0, + is_registered: true, + })), + { + item_id: IdMap.getId("existingLine2"), + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + is_requested: false, + is_registered: true, + quantity: 2, + metadata: {}, + }, + ], } + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("order-refund") }, + { + $set: { + returns: [toSet], + }, + } + ) + }) + + it("sets requires_action on unmatcing quantities", async () => { + await orderService.return( + IdMap.getId("order-refund"), + IdMap.getId("return"), + [ + { + item_id: IdMap.getId("existingLine"), + quantity: 1, + }, + ] + ) + + const originalReturn = orders.orderToRefund.returns[0] + const toSet = { + ...originalReturn, + status: "requires_action", + items: originalReturn.items.map(i => ({ + ...i, + requested_quantity: i.quantity, + quantity: 1, + is_requested: false, + is_registered: true, + })), + } + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("order-refund") }, + { + $set: { + returns: [toSet], + }, + } + ) + }) + }) + + describe("requestReturn", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + shippingOptionService: ShippingOptionServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + paymentProviderService: PaymentProviderServiceMock, + totalsService: TotalsServiceMock, + eventBusService: EventBusServiceMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("successfully creates return request", async () => { + const items = [ + { + item_id: IdMap.getId("existingLine"), + quantity: 10, + }, + ] + const shipping_method = { + id: IdMap.getId("return-shipping"), + price: 2, + } + await orderService.requestReturn( + IdMap.getId("processed-order"), + items, + shipping_method + ) + + expect(FulfillmentProviderMock.createReturn).toHaveBeenCalledTimes(1) + expect(FulfillmentProviderMock.createReturn).toHaveBeenCalledWith( + { + id: "return_shipment", + }, + [ + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + returned_quantity: 0, + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + orders.processedOrder + ) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("processed-order") }, + { + $push: { + returns: { + shipping_method: { + _id: IdMap.getId("return-shipping"), + is_return: true, + name: "Return Shipping", + region_id: IdMap.getId("region-france"), + profile_id: IdMap.getId("default-profile"), + data: { + id: "return_shipment", + }, + price: 2, + provider_id: "default_provider", + }, + shipping_data: { + id: "return_shipment", + shipped: true, + }, + items: [ + { + item_id: IdMap.getId("existingLine"), + content: { + unit_price: 123, + variant: { + _id: IdMap.getId("can-cover"), + }, + product: { + _id: IdMap.getId("validId"), + }, + quantity: 1, + }, + is_requested: true, + quantity: 10, + }, + ], + refund_amount: 1228, + }, + }, + } + ) + }) + + it("sets correct shipping method", async () => { + const items = [ + { + item_id: IdMap.getId("existingLine"), + quantity: 10, + }, + ] + await orderService.requestReturn(IdMap.getId("processed-order"), items) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect( + OrderModelMock.updateOne.mock.calls[0][1].$push.returns.refund_amount + ).toEqual(1230) + }) + + it("throws if payment is already processed", async () => { + await expect( + orderService.requestReturn(IdMap.getId("fulfilled-order"), []) + ).rejects.toThrow("Can't return an order with payment unprocessed") }) it("throws if return is attempted on unfulfilled order", async () => { + await expect( + orderService.requestReturn(IdMap.getId("not-fulfilled-order"), []) + ).rejects.toThrow("Can't return an unfulfilled or already returned order") + }) + }) + + describe("createShipment", () => { + const orderService = new OrderService({ + orderModel: OrderModelMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + eventBusService: EventBusServiceMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calls order model functions", async () => { + await orderService.createShipment( + IdMap.getId("test-order"), + IdMap.getId("fulfillment"), + ["1234", "2345"], + {} + ) + + expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(OrderModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("test-order"), + "fulfillments._id": IdMap.getId("fulfillment"), + }, + { + $set: { + "fulfillments.$": { + _id: IdMap.getId("fulfillment"), + provider_id: "default_provider", + tracking_numbers: ["1234", "2345"], + data: {}, + shipped_at: expect.anything(), + metadata: {}, + }, + }, + } + ) + }) + + it("throws if order is unprocessed", async () => { try { - await orderService.return(IdMap.getId("not-fulfilled-order"), []) + await orderService.archive(IdMap.getId("test-order")) } catch (error) { - expect(error.message).toEqual( - "Can't return an unfulfilled or already returned order" - ) + expect(error.message).toEqual("Can't archive an unprocessed order") } }) }) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index 286c9b3575..188843bc3c 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -1122,6 +1122,7 @@ class CartService extends BaseService { const newItems = await Promise.all( cart.items.map(async lineItem => { try { + lineItem.has_shipping = false lineItem.content = await this.updateContentPrice_( lineItem.content, region._id diff --git a/packages/medusa/src/services/document.js b/packages/medusa/src/services/document.js new file mode 100644 index 0000000000..2357c5b46f --- /dev/null +++ b/packages/medusa/src/services/document.js @@ -0,0 +1,138 @@ +import { Validator, MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" + +/** + * Provides layer to manipulate documents. + * @implements BaseService + */ +class DocumentService extends BaseService { + constructor({ documentModel, eventBusService }) { + super() + + /** @private @const {DocumentModel} */ + this.documentModel_ = documentModel + + /** @private @const {EventBus} */ + this.eventBus_ = eventBusService + } + + /** + * Used to validate document ids. Throws an error if the cast fails + * @param {string} rawId - the raw document id to validate. + * @return {string} the validated id + */ + validateId_(rawId) { + const schema = Validator.objectId() + const { value, error } = schema.validate(rawId.toString()) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "The documentId could not be casted to an ObjectId" + ) + } + + return value + } + + /** + * Creates a document. + * @return {Promise} the newly created document. + */ + async create(doc) { + return this.documentModel_.create(doc) + } + + /** + * Retrieve a document. + * @return {Promise} the document. + */ + async retrieve(id) { + const validatedId = this.validateId_(id) + return this.documentModel_.findOne({ _id: validatedId }).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Updates a customer. Metadata updates and address updates should + * use dedicated methods, e.g. `setMetadata`, etc. The function + * will throw errors if metadata updates and address updates are attempted. + * @param {string} variantId - the id of the variant. 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(id, update) { + const doc = await this.retrieve(id) + + if (update.metadata) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Use setMetadata to update metadata fields" + ) + } + + return this.documentModel_ + .updateOne({ _id: doc._id }, { $set: update }, { runValidators: true }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Deletes a document + * @param {string} id - the id of the document to delete. + * @return {Promise} the result of the delete operation. + */ + async delete(id) { + let doc + try { + doc = await this.retrieve(id) + } catch (error) { + return Promise.resolve() + } + + return this.documentModel_.deleteOne({ _id: doc._id }).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Decorates a document object. + * @param {Document} doc - the document to decorate. + * @param {string[]} fields - the fields to include. + * @param {string[]} expandFields - fields to expand. + * @return {Document} return the decorated doc. + */ + async decorate(doc, fields, expandFields = []) { + return doc + } + + /** + * Dedicated method to set metadata for a document. + * To ensure that plugins does not overwrite each + * others metadata fields, setMetadata is provided. + * @param {string} id - the document id + * @param {string} key - key for metadata field + * @param {string} value - value for metadata field. + * @return {Promise} resolves to the updated result. + */ + async setMetadata(id, key, value) { + const doc = await this.retrieve(id) + 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.documentModel_ + .updateOne({ _id: doc._id }, { $set: { [keyPath]: value } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } +} + +export default DocumentService diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index 1d349ad913..39d0fb0b45 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -8,7 +8,9 @@ class OrderService extends BaseService { PAYMENT_CAPTURED: "order.payment_captured", SHIPMENT_CREATED: "order.shipment_created", FULFILLMENT_CREATED: "order.fulfillment_created", + RETURN_REQUESTED: "order.return_requested", ITEMS_RETURNED: "order.items_returned", + RETURN_ACTION_REQUIRED: "order.return_action_required", REFUND_CREATED: "order.refund_created", PLACED: "order.placed", UPDATED: "order.updated", @@ -20,44 +22,53 @@ class OrderService extends BaseService { orderModel, counterService, paymentProviderService, + shippingOptionService, shippingProfileService, discountService, fulfillmentProviderService, lineItemService, totalsService, regionService, + documentService, eventBusService, }) { super() - /** @private @const {OrderModel} */ + /** @private @constantant {OrderModel} */ this.orderModel_ = orderModel - /** @private @const {PaymentProviderService} */ + /** @private @constantant {PaymentProviderService} */ this.paymentProviderService_ = paymentProviderService - /** @private @const {ShippingProvileService} */ + /** @private @constantant {ShippingProvileService} */ this.shippingProfileService_ = shippingProfileService - /** @private @const {FulfillmentProviderService} */ + /** @private @constant {FulfillmentProviderService} */ this.fulfillmentProviderService_ = fulfillmentProviderService - /** @private @const {LineItemService} */ + /** @private @constant {LineItemService} */ this.lineItemService_ = lineItemService - /** @private @const {TotalsService} */ + /** @private @constant {TotalsService} */ this.totalsService_ = totalsService - /** @private @const {RegionService} */ + /** @private @constant {RegionService} */ this.regionService_ = regionService - /** @private @const {DiscountService} */ + /** @private @constant {DiscountService} */ this.discountService_ = discountService - /** @private @const {EventBus} */ + /** @private @constant {EventBus} */ this.eventBus_ = eventBusService + /** @private @constant {DocumentService} */ + this.documentService_ = documentService + + /** @private @constant {CounterService} */ this.counterService_ = counterService + + /** @private @constant {ShippingOptionService} */ + this.shippingOptionService_ = shippingOptionService } /** @@ -383,7 +394,7 @@ class OrderService extends BaseService { cart_id: cart._id, tax_rate: region.tax_rate, currency_code: region.currency_code, - metadata: cart.metadata, + metadata: cart.metadata || {}, } const orderDocument = await this.orderModel_.create([o], { @@ -398,34 +409,44 @@ class OrderService extends BaseService { } /** - * Adds a shipment to the order to indicate that an order has left the warehouse + * Adds a shipment to the order to indicate that an order has left the + * warehouse. Will ask the fulfillment provider for any documents that may + * have been created in regards to the shipment. + * @param {string} orderId - the id of the order that has been shipped + * @param {string} fulfillmentId - the fulfillment that has now been shipped + * @param {Array} trackingNumbers - array of tracking numebers + * associated with the shipment + * @param {Dictionary} metadata - optional metadata to add to + * the fulfillment + * @return {order} the resulting order following the update. */ async createShipment(orderId, fulfillmentId, trackingNumbers, metadata = {}) { const order = await this.retrieve(orderId) - let shipment - const updated = order.fulfillments.map(f => { - if (f._id.equals(fulfillmentId)) { - shipment = { - ...f, - tracking_numbers: trackingNumbers, - shipped_at: Date.now(), - metadata: { - ...f.metadata, - ...metadata, - }, - } - return shipment - } - return f - }) + const shipment = order.fulfillments.find(f => f._id.equals(fulfillmentId)) + if (!shipment) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a fulfillment with the provided id` + ) + } + + const updated = { + ...shipment, + tracking_numbers: trackingNumbers, + shipped_at: Date.now(), + metadata: { + ...shipment.metadata, + ...metadata, + }, + } // Add the shipment to the order return this.orderModel_ .updateOne( - { _id: orderId }, + { _id: orderId, "fulfillments._id": fulfillmentId }, { - $set: { fulfillments: updated }, + $set: { "fulfillments.$": updated }, } ) .then(result => { @@ -639,6 +660,37 @@ class OrderService extends BaseService { }) } + /** + * Checks that a given quantity of a line item can be fulfilled. Fails if the + * fulfillable quantity is lower than the requested fulfillment quantity. + * Fulfillable quantity is calculated by subtracting the already fulfilled + * quantity from the quantity that was originally purchased. + * @param {LineItem} item - the line item to check has sufficient fulfillable + * quantity. + * @param {number} quantity - the quantity that is requested to be fulfilled. + * @return {LineItem} a line item that has the requested fulfillment quantity + * set. + */ + validateFulfillmentLineItem_(item, quantity) { + if (!item) { + // This will in most cases be called by a webhook so to ensure that + // things go through smoothly in instances where extra items outside + // of Medusa are added we allow unknown items + return null + } + + if (quantity > item.quantity - item.fulfilled_quantity) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot fulfill more items than have been purchased" + ) + } + return { + ...item, + quantity, + } + } + /** * Creates fulfillments for an order. * In a situation where the order has more than one shipping method, @@ -650,29 +702,11 @@ class OrderService extends BaseService { async createFulfillment(orderId, itemsToFulfill, metadata = {}) { const order = await this.retrieve(orderId) - const lineItems = itemsToFulfill - .map(({ item_id, quantity }) => { - const item = order.items.find(i => i._id.equals(item_id)) - - if (!item) { - // This will in most cases be called by a webhook so to ensure that - // things go through smoothly in instances where extra items outside - // of Medusa are added we allow unknown items - return null - } - - if (quantity > item.quantity - item.fulfilled_quantity) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Cannot fulfill more items than have been purchased" - ) - } - return { - ...item, - quantity, - } - }) - .filter(i => !!i) + const lineItems = await this.getFulfillmentItems_( + order, + itemsToFulfill, + this.validateFulfillmentLineItem_ + ) const { shipping_methods } = order @@ -713,12 +747,9 @@ class OrderService extends BaseService { updateFields.items = order.items.map(i => { const ful = successfullyFulfilled.find(f => i._id.equals(f._id)) if (ful) { - if (i.quantity === ful.quantity) { - i.fulfilled = true - } - return { ...i, + fulfilled: i.quantity === ful.quantity, fulfilled_quantity: ful.quantity, } } @@ -751,48 +782,66 @@ class OrderService extends BaseService { } /** - * 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 + * Retrieves the order line items, given an array of items. + * @param {Order} order - the order to get line items from + * @param {{ item_id: string, quantity: number }} items - the items to get + * @param {function} transformer - a function to apply to each of the items + * retrieved from the order, should return a line item. If the transformer + * returns an undefined value the line item will be filtered from the + * returned array. + * @return {Promise>} the line items generated by the transformer. */ - async return(orderId, lineItems, refundAmount) { - const order = await this.retrieve(orderId) + async getFulfillmentItems_(order, items, transformer) { + const toReturn = await Promise.all( + items.map(async ({ item_id, quantity }) => { + const item = order.items.find(i => i._id.equals(item_id)) + return transformer(item, quantity) + }) + ) - const total = await this.totalsService_.getTotal(order) - const refunded = await this.totalsService_.getRefundedTotal(order) + return toReturn.filter(i => !!i) + } - if (refundAmount > total - refunded) { + /** + * Checks that a given quantity of a line item can be returned. Fails if the + * item is undefined or if the returnable quantity of the item is lower, than + * the quantity that is requested to be returned. + * @param {LineItem?} item - the line item to check has sufficient returnable + * quantity. + * @param {number} quantity - the quantity that is requested to be returned. + * @return {LineItem} a line item where the quantity is set to the requested + * return quantity. + */ + validateReturnLineItem_(item, quantity) { + if (!item) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Cannot refund more than the original payment" + "Return contains invalid line item" ) } - // Find the lines to return - const returnLines = lineItems.map(({ item_id, quantity }) => { - const item = order.items.find(i => i._id.equals(item_id)) - if (!item) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Return contains invalid line item" - ) - } + const returnable = item.quantity - item.returned_quantity + if (quantity > returnable) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot return more items than have been purchased" + ) + } - const returnable = item.quantity - item.returned_quantity - if (quantity > returnable) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Cannot return more items than have been purchased" - ) - } - - return { - ...item, - quantity, - } - }) + return { + ...item, + quantity, + } + } + /** + * Checks that an order has the statuses necessary to complete a return. + * fulfillment_status cannot be not_fulfilled or returned. + * payment_status must be captured. + * @param {Order} order - the order to check statuses on + * @throws when statuses are not sufficient for returns. + */ + validateReturnStatuses_(order) { if ( order.fulfillment_status === "not_fulfilled" || order.fulfillment_status === "returned" @@ -809,25 +858,261 @@ class OrderService extends BaseService { "Can't return an order with payment unprocessed" ) } + } - const { provider_id, data } = order.payment_method - const paymentProvider = this.paymentProviderService_.retrieveProvider( - provider_id + /** + * Generates documents. + * @param {Array} docs - documents to generate + * @param {Function} transformer - a function to apply to the created document + * before returning. + * @return {Promise>} returns the created documents + */ + createDocuments_(docs, transformer) { + return Promise.all( + docs.map(async d => { + const doc = await this.documentService_.create(d) + return transformer(doc) + }) + ) + } + + /** + * Creates a return request for an order, with given items, and a shipping + * method. If no refundAmount is provided the refund amount is calculated from + * the return lines and the shipping cost. + * @param {String} orderId - the id of the order to create a return for. + * @param {Array<{item_id: String, quantity: Int}>} items - the line items to + * return + * @param {ShippingMethod?} shippingMethod - the shipping method used for the + * return + * @param {Number?} refundAmount - the amount to refund when the return is + * received. + * @returns {Promise} the resulting order. + */ + async requestReturn(orderId, items, shippingMethod, refundAmount) { + const order = await this.retrieve(orderId) + + // Throws if the order doesn't have the necessary status for return + this.validateReturnStatuses_(order) + + let toRefund = refundAmount + if (typeof refundAmount !== "undefined") { + const total = await this.totalsService_.getTotal(order) + const refunded = await this.totalsService_.getRefundedTotal(order) + const refundable = total - refunded + if (refundAmount > refundable) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Cannot refund more than the original payment" + ) + } + } else { + toRefund = await this.totalsService_.getRefundTotal(order, returnLines) + } + + const returnLines = await this.getFulfillmentItems_( + order, + items, + this.validateReturnLineItem_ ) - const amount = - refundAmount || this.totalsService_.getRefundTotal(order, returnLines) - await paymentProvider.refundPayment(data, amount) + let fulfillmentData = {} + let shipping_method = {} + if (typeof shippingMethod !== "undefined") { + shipping_method = await this.shippingOptionService_.retrieve( + shippingMethod.id + ) + const provider = await this.fulfillmentProviderService_.retrieveProvider( + shipping_method.provider_id + ) + fulfillmentData = await provider.createReturn( + shipping_method.data, + returnLines, + order + ) + + if (typeof shippingMethod.price !== "undefined") { + shipping_method.price = shippingMethod.price + } else { + shipping_method.price = await this.shippingOptionService_.getPrice( + shipping_method, + { + ...order, + items: returnLines, + } + ) + } + + toRefund = Math.max(0, toRefund - shipping_method.price) + } + + const newReturn = { + shipping_method, + refund_amount: toRefund, + items: returnLines.map(i => ({ + item_id: i._id, + content: i.content, + quantity: i.quantity, + is_requested: true, + metadata: i.metadata, + })), + shipping_data: fulfillmentData, + } + + return this.orderModel_ + .updateOne( + { + _id: order._id, + }, + { + $push: { + returns: newReturn, + }, + } + ) + .then(result => { + this.eventBus_.emit(OrderService.Events.RETURN_REQUESTED, { + order: result, + return: newReturn, + }) + return result + }) + } + + /** + * Registers a previously requested return as received. This will create a + * refund to the customer. If the returned items don't match the requested + * items the return status will be updated to requires_action. This behaviour + * is useful in sitautions where a custom refund amount is requested, but the + * retuned items are not matching the requested items. Setting the + * allowMismatch argument to true, will process the return, ignoring any + * mismatches. + * @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, returnId, items, refundAmount, allowMismatch = false) { + const order = await this.retrieve(orderId) + const returnRequest = order.returns.find(r => r._id.equals(returnId)) + if (!returnRequest) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Return request with id ${returnId} was not found` + ) + } + + if (returnRequest.status === "received") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Return with id ${returnId} has already been received` + ) + } + + const returnLines = await this.getFulfillmentItems_( + order, + items, + this.validateReturnLineItem_ + ) + + const newLines = returnLines.map(l => { + const existing = returnRequest.items.find(i => l._id.equals(i.item_id)) + if (existing) { + return { + ...existing, + quantity: l.quantity, + requested_quantity: existing.quantity, + is_requested: l.quantity === existing.quantity, + is_registered: true, + } + } else { + return { + item_id: l._id, + content: l.content, + quantity: l.quantity, + is_requested: false, + is_registered: true, + metadata: l.metadata, + } + } + }) + + const isMatching = newLines.every(l => l.is_requested) + if (!isMatching && !allowMismatch) { + // Should update status + const newReturns = order.returns.map(r => { + if (r._id.equals(returnId)) { + return { + ...r, + status: "requires_action", + items: newLines, + } + } else { + return r + } + }) + return this.orderModel_ + .updateOne( + { + _id: orderId, + }, + { + $set: { + returns: newReturns, + }, + } + ) + .then(result => { + this.eventBus_.emit(OrderService.Events.RETURN_ACTION_REQUIRED, { + order: result, + return: result.returns.find(r => r._id.equals(returnId)), + }) + return result + }) + } + + const toRefund = refundAmount || returnRequest.refund_amount + const total = await this.totalsService_.getTotal(order) + const refunded = await this.totalsService_.getRefundedTotal(order) + + if (toRefund > total - refunded) { + const newReturns = order.returns.map(r => { + if (r._id.equals(returnId)) { + return { + ...r, + status: "requires_action", + items: newLines, + } + } else { + return r + } + }) + return this.orderModel_ + .updateOne( + { + _id: orderId, + }, + { + $set: { + returns: newReturns, + }, + } + ) + .then(result => { + this.eventBus_.emit(OrderService.Events.RETURN_ACTION_REQUIRED, { + order: result, + return: result.returns.find(r => r._id.equals(returnId)), + }) + return result + }) + } let isFullReturn = true const newItems = order.items.map(i => { const isReturn = returnLines.find(r => r._id.equals(i._id)) if (isReturn) { const returnedQuantity = i.returned_quantity + isReturn.quantity - let returned = false - if (i.quantity === returnedQuantity) { - returned = true - } else { + let returned = i.quantity === returnedQuantity + if (!returned) { isFullReturn = false } return { @@ -843,39 +1128,47 @@ class OrderService extends BaseService { } }) - return this.orderModel_ - .updateOne( - { - _id: orderId, - }, - { - $push: { - refunds: { - amount, - }, - returns: { - items: lineItems, - refund_amount: amount, - }, - }, - $set: { - items: newItems, - fulfillment_status: isFullReturn - ? "returned" - : "partially_returned", - }, + const newReturns = order.returns.map(r => { + if (r._id.equals(returnId)) { + return { + ...r, + status: "received", + items: newLines, + refund_amount: toRefund, } + } else { + return r + } + }) + + const update = { + $set: { + returns: newReturns, + items: newItems, + fulfillment_status: isFullReturn ? "returned" : "partially_returned", + }, + } + + if (toRefund > 0) { + const { provider_id, data } = order.payment_method + const paymentProvider = this.paymentProviderService_.retrieveProvider( + provider_id ) - .then(result => { - this.eventBus_.emit(OrderService.Events.ITEMS_RETURNED, { - order: result, - return: { - items: lineItems, - refund_amount: amount, - }, - }) - return result + await paymentProvider.refundPayment(data, toRefund) + update.$push = { + refunds: { + amount: toRefund, + }, + } + } + + return this.orderModel_.updateOne({ _id: orderId }, update).then(result => { + this.eventBus_.emit(OrderService.Events.ITEMS_RETURNED, { + order: result, + return: result.returns.find(r => r._id.equals(returnId)), }) + return result + }) } /** diff --git a/packages/medusa/src/services/shipping-option.js b/packages/medusa/src/services/shipping-option.js index 0e592c172c..73cd79c002 100644 --- a/packages/medusa/src/services/shipping-option.js +++ b/packages/medusa/src/services/shipping-option.js @@ -85,6 +85,10 @@ class ShippingOptionService extends BaseService { query.region_id = selector.region_id } + if ("is_return" in selector) { + query.is_return = selector.is_return.toLowerCase() === "true" + } + return this.optionModel_.find(query) } @@ -138,6 +142,10 @@ class ShippingOptionService extends BaseService { async validateCartOption(optionId, cart) { const option = await this.retrieve(optionId) + if (option.is_return) { + return null + } + if (cart.region_id !== option.region_id) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -163,22 +171,17 @@ class ShippingOptionService extends BaseService { ) } - if (option.price && option.price.type === "calculated") { - const provider = this.providerService_.retrieveProvider( - option.provider_id - ) - option.price = await provider.calculatePrice(option.data, cart) - } else { - option.price = option.price.amount - } + option.price = await this.getPrice(option, cart) return option } /** - * Creates a new shipping option. + * Creates a new shipping option. Used both for outbound and inbound shipping + * options. The difference is registered by the `is_return` field which + * defaults to false. * @param {ShippingOption} option - the shipping option to create - * @return {Promise} the result of the create operation + * @return {Promise} the result of the create operation */ async create(option) { const region = await this.regionService_.retrieve(option.region_id) @@ -434,6 +437,26 @@ class ShippingOptionService extends BaseService { }) } + /** + * Returns the amount to be paid for a shipping method. Will ask the + * fulfillment provider to calculate the price if the shipping option has the + * price type "calculated". + * @param {ShippingOption} option - the shipping option to retrieve the price + * for. + * @param {Cart || Order} cart - the context in which the price should be + * retrieved. + * @returns {Promise} the price of the shipping option. + */ + async getPrice(option, cart) { + if (option.price && option.price.type === "calculated") { + const provider = this.providerService_.retrieveProvider( + option.provider_id + ) + return provider.calculatePrice(option.data, cart) + } + return option.price.amount + } + /** * Dedicated method to delete metadata for a shipping option. * @param {string} optionId - the shipping option to delete metadata from.