From fd0423323884089b4f5c17381638ded072964c78 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Tue, 24 Nov 2020 14:02:49 +0100 Subject: [PATCH] feature: swaps (#137) * feat: swap schema * chore(refactor): move returns to separate service * swap order creates return * fix: adds returns/fulfillments/shipments and cart creation to swaps * search products by variants * feat: swaps ready * fix: test passing * chore: include customer expand fields on order * add tests * fix: add brightpearl plugin support * fix: adds swap link template setting * fix: tests * fix(medusa-plugin-brightpearl): add swap status option * docs: add swap description * fix: adds GET swap in store api * chore: unmock axios * fix: passing klarna tests * fix: pr --- docs/services/OrderService.md | 11 + .../src/api/index.js | 5 +- .../src/services/webshipper-fulfillment.js | 39 +- .../medusa-interfaces/src/base-service.js | 4 +- packages/medusa-payment-klarna/package.json | 3 +- .../src/__mocks__/axios.js | 5 - .../src/api/routes/hooks/push.js | 12 +- .../src/services/__tests__/klarna-provider.js | 160 ++-- .../src/services/klarna-provider.js | 6 +- packages/medusa-payment-klarna/yarn.lock | 139 ++-- packages/medusa-plugin-brightpearl/README.md | 1 + .../src/services/brightpearl.js | 206 ++++- .../src/subscribers/order.js | 45 +- .../routes/admin/customers/get-customer.js | 8 +- .../routes/admin/customers/update-customer.js | 7 + .../api/routes/admin/orders/archive-order.js | 6 +- .../api/routes/admin/orders/cancel-order.js | 6 +- .../routes/admin/orders/capture-payment.js | 6 +- .../api/routes/admin/orders/capture-swap.js | 23 + .../api/routes/admin/orders/complete-order.js | 2 +- .../routes/admin/orders/create-fulfillment.js | 10 +- .../routes/admin/orders/create-shipment.js | 6 +- .../admin/orders/create-swap-shipment.js | 41 + .../api/routes/admin/orders/create-swap.js | 67 ++ .../api/routes/admin/orders/fulfill-swap.js | 36 + .../src/api/routes/admin/orders/get-order.js | 10 +- .../src/api/routes/admin/orders/index.js | 37 + .../api/routes/admin/orders/receive-return.js | 8 +- .../api/routes/admin/orders/receive-swap.js | 44 ++ .../api/routes/admin/orders/refund-payment.js | 6 +- .../api/routes/admin/orders/request-return.js | 6 +- .../api/routes/admin/orders/set-metadata.js | 6 +- .../api/routes/admin/orders/update-order.js | 6 +- .../routes/admin/products/list-products.js | 17 + .../api/routes/admin/store/update-store.js | 1 + packages/medusa/src/api/routes/store/index.js | 2 + .../src/api/routes/store/swaps/create-swap.js | 26 + .../routes/store/swaps/get-swap-by-cart.js | 11 + .../src/api/routes/store/swaps/index.js | 16 + packages/medusa/src/models/__mocks__/order.js | 1 + packages/medusa/src/models/cart.js | 2 + packages/medusa/src/models/order.js | 6 +- .../src/models/schemas/return-line-item.js | 2 +- packages/medusa/src/models/store.js | 1 + packages/medusa/src/models/swap.js | 35 + .../medusa/src/services/__mocks__/order.js | 1 + .../medusa/src/services/__tests__/cart.js | 52 ++ .../medusa/src/services/__tests__/order.js | 294 +++++-- .../medusa/src/services/__tests__/swap.js | 731 ++++++++++++++++++ packages/medusa/src/services/cart.js | 21 +- packages/medusa/src/services/customer.js | 14 +- packages/medusa/src/services/fulfillment.js | 157 ++++ packages/medusa/src/services/order.js | 509 +++++------- packages/medusa/src/services/return.js | 274 +++++++ .../medusa/src/services/shipping-profile.js | 7 +- packages/medusa/src/services/swap.js | 673 ++++++++++++++++ 56 files changed, 3266 insertions(+), 564 deletions(-) delete mode 100644 packages/medusa-payment-klarna/src/__mocks__/axios.js create mode 100644 packages/medusa/src/api/routes/admin/orders/capture-swap.js create mode 100644 packages/medusa/src/api/routes/admin/orders/create-swap-shipment.js create mode 100644 packages/medusa/src/api/routes/admin/orders/create-swap.js create mode 100644 packages/medusa/src/api/routes/admin/orders/fulfill-swap.js create mode 100644 packages/medusa/src/api/routes/admin/orders/receive-swap.js create mode 100644 packages/medusa/src/api/routes/store/swaps/create-swap.js create mode 100644 packages/medusa/src/api/routes/store/swaps/get-swap-by-cart.js create mode 100644 packages/medusa/src/api/routes/store/swaps/index.js create mode 100644 packages/medusa/src/models/swap.js create mode 100644 packages/medusa/src/services/__tests__/swap.js create mode 100644 packages/medusa/src/services/fulfillment.js create mode 100644 packages/medusa/src/services/return.js create mode 100644 packages/medusa/src/services/swap.js diff --git a/docs/services/OrderService.md b/docs/services/OrderService.md index b0ab513ecb..7bfdcbe241 100644 --- a/docs/services/OrderService.md +++ b/docs/services/OrderService.md @@ -18,3 +18,14 @@ A different flow that is less common follows the steps: 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. + +## Swaps + +A swap can be used in cases where a customer wishes to exchange previously purchased items for different items. Usually this occurs if the customer wants to change the size or color of an item. In Medusa a swap can be initiated to handle the administrative tasks around swaps i.e. requesting a return, taking a payment from the customer for any shipping expenses and fulfilling the new items. + +When a swap is created in Medusa Admin a return request will be initiated immediately generating return labels with the chosen fulfillment provider, furthermore a cart will be created and a payment link generated which can be sent to the customer to authorize a payment. When the return is received the swap can be marked as received and usually this will be where the new goods are to be sent out. The new goods are sent with the shipping method chosen by the customer in the payment process. + +Limitations: +- At the moment only swaps that require a payment are supported i.e. swaps that would result in a refund to the customer are not possible. +- The customer must have paid for their swap before the items can be returned. + diff --git a/packages/medusa-fulfillment-webshipper/src/api/index.js b/packages/medusa-fulfillment-webshipper/src/api/index.js index 8dc873aa6c..9cbbe9b0e2 100644 --- a/packages/medusa-fulfillment-webshipper/src/api/index.js +++ b/packages/medusa-fulfillment-webshipper/src/api/index.js @@ -48,10 +48,13 @@ export default (rootDirectory) => { "/webshipper/shipments", bodyParser.raw({ type: "application/vnd.api+json" }), async (req, res) => { + const webshipperService = req.scope.resolve( + "webshipperFulfillmentService" + ) const eventBus = req.scope.resolve("eventBusService") const logger = req.scope.resolve("logger") - const secret = `da791d87513eb091640f9fb6c4b94384` + const secret = webshipperService.options_.webhook_secret const hmac = crypto.createHmac("sha256", secret) const digest = hmac.update(req.body).digest("base64") const hash = req.header("x-webshipper-hmac-sha256") diff --git a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js index 66e9f97dd4..3ae5682334 100644 --- a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js +++ b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js @@ -4,12 +4,13 @@ import Webshipper from "../utils/webshipper" class WebshipperFulfillmentService extends FulfillmentService { static identifier = "webshipper" - constructor({ logger, orderService }, options) { + constructor({ logger, swapService, orderService }, options) { super() this.logger_ = logger this.orderService_ = orderService this.options_ = options + this.swapService_ = swapService this.client_ = new Webshipper({ account: this.options_.account, token: this.options_.api_token, @@ -227,15 +228,23 @@ class WebshipperFulfillmentService extends FulfillmentService { }) } + let visible_ref = `${fromOrder.display_id}-${ + fromOrder.fulfillments.length + 1 + }` + let ext_ref = `${fromOrder._id}.${fromOrder.fulfillments.length}` + + if (fromOrder.is_swap) { + ext_ref = `S${fromOrder._id}.${fromOrder.fulfillments.length}` + visible_ref = `S-${fromOrder.display_id}` + } + const { shipping_address } = fromOrder const newOrder = { type: "orders", attributes: { status: "pending", - ext_ref: `${fromOrder._id}.${fromOrder.fulfillments.length}`, - visible_ref: `${fromOrder.display_id}-${ - fromOrder.fulfillments.length + 1 - }`, + ext_ref, + visible_ref, order_lines: fulfillmentItems.map((item) => { return { ext_ref: item._id, @@ -325,14 +334,24 @@ class WebshipperFulfillmentService extends FulfillmentService { "." ) - const order = await this.orderService_.retrieve(orderId) - const fulfillment = order.fulfillments[fulfillmentIndex] - if (fulfillment) { - await this.orderService_.createShipment( - order._id, + if (orderId.charAt(0) === "S") { + const swap = await this.swapService_.retrieve(orderId.substring(1)) + const fulfillment = swap.fulfillments[fulfillmentIndex] + await this.swapService_.createShipment( + swap._id, fulfillment._id, trackingNumbers ) + } else { + const order = await this.orderService_.retrieve(orderId) + const fulfillment = order.fulfillments[fulfillmentIndex] + if (fulfillment) { + await this.orderService_.createShipment( + order._id, + fulfillment._id, + trackingNumbers + ) + } } } } diff --git a/packages/medusa-interfaces/src/base-service.js b/packages/medusa-interfaces/src/base-service.js index a6abcc8302..b9117099d5 100644 --- a/packages/medusa-interfaces/src/base-service.js +++ b/packages/medusa-interfaces/src/base-service.js @@ -27,9 +27,9 @@ class BaseService { * @param {object} obj - the object to decorate. * @return {object} the decorated object. */ - runDecorators_(obj) { + runDecorators_(obj, fields = [], expandFields = []) { return this.decorators_.reduce(async (acc, next) => { - return acc.then(res => next(res)).catch(() => acc) + return acc.then(res => next(res, fields, expandFields)).catch(() => acc) }, Promise.resolve(obj)) } } diff --git a/packages/medusa-payment-klarna/package.json b/packages/medusa-payment-klarna/package.json index ba64cea9e4..0857992d18 100644 --- a/packages/medusa-payment-klarna/package.json +++ b/packages/medusa-payment-klarna/package.json @@ -20,6 +20,7 @@ "@babel/preset-env": "^7.7.5", "@babel/register": "^7.7.4", "@babel/runtime": "^7.9.6", + "axios-mock-adapter": "^1.19.0", "client-sessions": "^0.8.0", "cross-env": "^5.2.1", "eslint": "^6.8.0", @@ -36,7 +37,7 @@ }, "dependencies": { "@babel/plugin-transform-classes": "^7.9.5", - "axios": "^0.19.2", + "axios": "^0.21.0", "body-parser": "^1.19.0", "express": "^4.17.1", "medusa-core-utils": "^1.0.10", diff --git a/packages/medusa-payment-klarna/src/__mocks__/axios.js b/packages/medusa-payment-klarna/src/__mocks__/axios.js deleted file mode 100644 index 8a6daf15a8..0000000000 --- a/packages/medusa-payment-klarna/src/__mocks__/axios.js +++ /dev/null @@ -1,5 +0,0 @@ -const mockAxios = jest.genMockFromModule("axios") - -mockAxios.create = jest.fn(() => mockAxios) - -export default mockAxios diff --git a/packages/medusa-payment-klarna/src/api/routes/hooks/push.js b/packages/medusa-payment-klarna/src/api/routes/hooks/push.js index 2e9a6a9796..90e378602a 100644 --- a/packages/medusa-payment-klarna/src/api/routes/hooks/push.js +++ b/packages/medusa-payment-klarna/src/api/routes/hooks/push.js @@ -10,17 +10,23 @@ export default async (req, res) => { const klarnaOrder = await klarnaProviderService.retrieveCompletedOrder( klarna_order_id - ).then(({ data }) => data) + ) const cartId = klarnaOrder.merchant_data try { const order = await orderService.retrieveByCartId(cartId) - await klarnaProviderService.acknowledgeOrder(klarnaOrder.order_id, order._id) + await klarnaProviderService.acknowledgeOrder( + klarnaOrder.order_id, + order._id + ) } catch (err) { if (err.type === MedusaError.Types.NOT_FOUND) { const cart = await cartService.retrieve(cartId) const order = await orderService.createFromCart(cart) - await klarnaProviderService.acknowledgeOrder(klarnaOrder.order_id, order._id) + await klarnaProviderService.acknowledgeOrder( + klarnaOrder.order_id, + order._id + ) } } diff --git a/packages/medusa-payment-klarna/src/services/__tests__/klarna-provider.js b/packages/medusa-payment-klarna/src/services/__tests__/klarna-provider.js index ac0505436e..5f58277645 100644 --- a/packages/medusa-payment-klarna/src/services/__tests__/klarna-provider.js +++ b/packages/medusa-payment-klarna/src/services/__tests__/klarna-provider.js @@ -1,14 +1,11 @@ +import MockAdapter from "axios-mock-adapter" + import KlarnaProviderService from "../klarna-provider" -import mockAxios from "../../__mocks__/axios" import { carts } from "../../__mocks__/cart" import { TotalsServiceMock } from "../../__mocks__/totals" import { RegionServiceMock } from "../../__mocks__/region" describe("KlarnaProviderService", () => { - beforeEach(() => { - mockAxios.mockClear() - }) - describe("createPayment", () => { const klarnaProviderService = new KlarnaProviderService( { @@ -27,22 +24,19 @@ describe("KlarnaProviderService", () => { } ) + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer.onPost("/checkout/v3/orders").reply(() => { + return [200, { order_id: "123456789", order_amount: 100 }] + }) + beforeEach(() => { jest.clearAllMocks() }) it("creates Klarna order", async () => { - mockAxios.post = jest.fn().mockImplementation(() => { - return Promise.resolve({ - data: { - order_id: "123456789", - order_amount: 100, - }, - }) - }) - const result = await klarnaProviderService.createPayment(carts.frCart) - expect(mockAxios.post).toHaveBeenCalledTimes(1) + + // expect(mockAxios.post).toHaveBeenCalledTimes(1) expect(result).toEqual({ order_id: "123456789", order_amount: 100, @@ -67,21 +61,14 @@ describe("KlarnaProviderService", () => { } ) - it("returns Klarna order", async () => { - mockAxios.get.mockImplementation((data) => { - return Promise.resolve({ - data: { - order_id: "123456789", - }, - }) - }) + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer.onGet("/checkout/v3/orders/123456789").reply(() => { + return [200, { order_id: "123456789" }] + }) + it("returns Klarna order", async () => { result = await klarnaProviderService.retrievePayment({ - payment_method: { - data: { - id: "123456789", - }, - }, + order_id: "123456789", }) expect(result).toEqual({ @@ -107,20 +94,13 @@ describe("KlarnaProviderService", () => { } ) - it("returns completed Klarna order", async () => { - mockAxios.get.mockImplementation((data) => { - return Promise.resolve({ - order_id: "123456789", - }) - }) + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer.onGet("/ordermanagement/v1/orders/123456789").reply(() => { + return [200, { order_id: "123456789" }] + }) - result = await klarnaProviderService.retrieveCompletedOrder({ - payment_method: { - data: { - id: "123456789", - }, - }, - }) + it("returns completed Klarna order", async () => { + result = await klarnaProviderService.retrieveCompletedOrder("123456789") expect(result).toEqual({ order_id: "123456789", @@ -150,24 +130,21 @@ describe("KlarnaProviderService", () => { }, } ) + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer.onPost("/checkout/v3/orders/123456789").reply(() => { + return [ + 200, + { + order_id: "123456789", + order_amount: 1000, + }, + ] + }) it("returns updated Klarna order", async () => { - mockAxios.post.mockImplementation((data) => { - return Promise.resolve({ - data: { - order_id: "123456789", - order_amount: 1000, - }, - }) - }) - result = await klarnaProviderService.updatePayment( { - payment_method: { - data: { - id: "123456789", - }, - }, + order_id: "123456789", }, carts.frCart ) @@ -195,12 +172,14 @@ describe("KlarnaProviderService", () => { password: "123456789", } ) - - it("returns order id", async () => { - mockAxios.post.mockImplementation((data) => { - return Promise.resolve() + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer + .onPost("/ordermanagement/v1/orders/123456789/cancel") + .reply(() => { + return [200] }) + it("returns order id", async () => { result = await klarnaProviderService.cancelPayment({ order_id: "123456789", }) @@ -226,12 +205,20 @@ describe("KlarnaProviderService", () => { password: "123456789", } ) - - it("returns order id", async () => { - mockAxios.post.mockImplementation((data) => { - return Promise.resolve({}) + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer + .onPost("/ordermanagement/v1/orders/123456789/acknowledge") + .reply(() => { + return [200] }) + mockServer + .onPatch("/ordermanagement/v1/orders/123456789/merchant-references") + .reply(() => { + return [200] + }) + + it("returns order id", async () => { result = await klarnaProviderService.acknowledgeOrder("123456789") expect(result).toEqual("123456789") @@ -255,12 +242,14 @@ describe("KlarnaProviderService", () => { password: "123456789", } ) - - it("returns order id", async () => { - mockAxios.post.mockImplementation((data) => { - return Promise.resolve() + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer + .onPost("/ordermanagement/v1/orders/123456789/merchant-references") + .reply(() => { + return [200] }) + it("returns order id", async () => { result = await klarnaProviderService.addOrderToKlarnaOrder( "123456789", "order123456789" @@ -287,20 +276,23 @@ describe("KlarnaProviderService", () => { password: "123456789", } ) + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer.onGet("/ordermanagement/v1/orders/123456789").reply(() => { + return [ + 200, + { + order: { order_amount: 1000 }, + }, + ] + }) + + mockServer + .onPost("/ordermanagement/v1/orders/123456789/captures") + .reply(() => { + return [200] + }) it("returns order id", async () => { - mockAxios.get.mockImplementation((data) => { - return Promise.resolve({ - data: { - order: { order_amount: 1000 }, - }, - }) - }) - - mockAxios.post.mockImplementation((data) => { - return Promise.resolve() - }) - result = await klarnaProviderService.capturePayment({ order_id: "123456789", }) @@ -326,12 +318,14 @@ describe("KlarnaProviderService", () => { password: "123456789", } ) - - it("returns order id", async () => { - mockAxios.post.mockImplementation((data) => { - return Promise.resolve() + const mockServer = new MockAdapter(klarnaProviderService.klarna_) + mockServer + .onPost("/ordermanagement/v1/orders/123456789/refunds") + .reply(() => { + return [200] }) + it("returns order id", async () => { result = await klarnaProviderService.refundPayment( { order_id: "123456789", diff --git a/packages/medusa-payment-klarna/src/services/klarna-provider.js b/packages/medusa-payment-klarna/src/services/klarna-provider.js index 3ca37df004..71160356eb 100644 --- a/packages/medusa-payment-klarna/src/services/klarna-provider.js +++ b/packages/medusa-payment-klarna/src/services/klarna-provider.js @@ -279,9 +279,9 @@ class KlarnaProviderService extends PaymentService { */ async retrieveCompletedOrder(klarnaOrderId) { try { - return this.klarna_.get( - `${this.klarnaOrderManagementUrl_}/${klarnaOrderId}` - ) + return this.klarna_ + .get(`${this.klarnaOrderManagementUrl_}/${klarnaOrderId}`) + .then(({ data }) => data) } catch (error) { throw error } diff --git a/packages/medusa-payment-klarna/yarn.lock b/packages/medusa-payment-klarna/yarn.lock index c9958995fc..01c50794f0 100644 --- a/packages/medusa-payment-klarna/yarn.lock +++ b/packages/medusa-payment-klarna/yarn.lock @@ -964,43 +964,17 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@hapi/address@^2.1.2": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" - integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ== +"@hapi/hoek@^9.0.0": + version "9.1.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.1.0.tgz#6c9eafc78c1529248f8f4d92b0799a712b6052c6" + integrity sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw== -"@hapi/formula@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-1.2.0.tgz#994649c7fea1a90b91a0a1e6d983523f680e10cd" - integrity sha512-UFbtbGPjstz0eWHb+ga/GM3Z9EzqKXFWIbSOFURU0A/Gku0Bky4bCk9/h//K2Xr3IrCfjFNhMm4jyZ5dbCewGA== - -"@hapi/hoek@^8.2.4", "@hapi/hoek@^8.3.0": - version "8.5.1" - resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06" - integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow== - -"@hapi/joi@^16.1.8": - version "16.1.8" - resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-16.1.8.tgz#84c1f126269489871ad4e2decc786e0adef06839" - integrity sha512-wAsVvTPe+FwSrsAurNt5vkg3zo+TblvC5Bb1zMVK6SJzZqw9UrJnexxR+76cpePmtUZKHAPxcQ2Bf7oVHyahhg== +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== dependencies: - "@hapi/address" "^2.1.2" - "@hapi/formula" "^1.2.0" - "@hapi/hoek" "^8.2.4" - "@hapi/pinpoint" "^1.0.2" - "@hapi/topo" "^3.1.3" - -"@hapi/pinpoint@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-1.0.2.tgz#025b7a36dbbf4d35bf1acd071c26b20ef41e0d13" - integrity sha512-dtXC/WkZBfC5vxscazuiJ6iq4j9oNx1SHknmIr8hofarpKUZKmlUVYVIhNVzIEgK5Wrc4GMHL5lZtt1uS2flmQ== - -"@hapi/topo@^3.1.3": - version "3.1.6" - resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29" - integrity sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ== - dependencies: - "@hapi/hoek" "^8.3.0" + "@hapi/hoek" "^9.0.0" "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" @@ -1185,6 +1159,23 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" +"@sideway/address@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.0.tgz#0b301ada10ac4e0e3fa525c90615e0b61a72b78d" + integrity sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" + integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sinonjs/commons@^1.7.0": version "1.7.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" @@ -1472,12 +1463,20 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== -axios@^0.19.2: - version "0.19.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" - integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== +axios-mock-adapter@^1.19.0: + version "1.19.0" + resolved "https://registry.yarnpkg.com/axios-mock-adapter/-/axios-mock-adapter-1.19.0.tgz#9d72e321a6c5418e1eff067aa99761a86c5188a4" + integrity sha512-D+0U4LNPr7WroiBDvWilzTMYPYTuZlbo6BI8YHZtj7wYQS8NkARlP9KBt8IWWHTQJ0q/8oZ0ClPBtKCCkx8cQg== dependencies: - follow-redirects "1.5.10" + fast-deep-equal "^3.1.3" + is-buffer "^2.0.3" + +axios@^0.21.0: + version "0.21.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.0.tgz#26df088803a2350dff2c27f96fef99fe49442aca" + integrity sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw== + dependencies: + follow-redirects "^1.10.0" babel-jest@^25.5.1: version "25.5.1" @@ -2006,7 +2005,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3: dependencies: ms "2.0.0" -debug@3.1.0, debug@=3.1.0: +debug@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== @@ -2477,6 +2476,11 @@ fast-deep-equal@^3.1.1: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA== +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -2581,12 +2585,10 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== -follow-redirects@1.5.10: - version "1.5.10" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" - integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== - dependencies: - debug "=3.1.0" +follow-redirects@^1.10.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" + integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== for-in@^1.0.2: version "1.0.2" @@ -2991,6 +2993,11 @@ is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-buffer@^2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-callable@^1.1.4, is-callable@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" @@ -3588,6 +3595,17 @@ joi-objectid@^3.0.1: resolved "https://registry.yarnpkg.com/joi-objectid/-/joi-objectid-3.0.1.tgz#63ace7860f8e1a993a28d40c40ffd8eff01a3668" integrity sha512-V/3hbTlGpvJ03Me6DJbdBI08hBTasFOmipsauOsxOSnsF1blxV537WTl1zPwbfcKle4AK0Ma4OPnzMH4LlvTpQ== +joi@^17.2.1: + version "17.3.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.3.0.tgz#f1be4a6ce29bc1716665819ac361dfa139fff5d2" + integrity sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.0" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3839,26 +3857,21 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@^0.3.0: - version "0.1.39" - resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-0.1.39.tgz#d57816c9bd43f9a92883650c1e66add1665291df" - integrity sha512-R8+U1ile7if+nR6Cjh5exunx0ETV0OfkWUUBUpz1KmHSDv0V0CcvQqU9lcZesPFDEbu3Y2iEjsCqidVA4nG2nQ== +medusa-core-utils@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.0.10.tgz#6175f1e428318205742621f78d387254fc85b8a1" + integrity sha512-z12ITzPl5UpDJTILNXcMGD4yujSRkuvVtxkrvqCmA44IEyRj1hWZ0dGbVzuHj2wIahDHFFg66oq91IQH5QVsyg== dependencies: - "@hapi/joi" "^16.1.8" + joi "^17.2.1" joi-objectid "^3.0.1" -medusa-interfaces@^0.3.0: - version "0.1.39" - resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-0.1.39.tgz#633db3c8c6afd7fec9ae24496737369d840105cf" - integrity sha512-byKIcK7o3L4shmGn+pgZAUyLrT991zCqK4jWXIleQJbGImQy6TmdXido+tEzFptVBJWMIQ8BWnP/b7r29D8EXA== - dependencies: - mongoose "^5.8.0" - -medusa-test-utils@^0.3.0: - version "0.1.39" - resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-0.1.39.tgz#b7c166006a2fa4f02e52ab3bfafc19a3ae787f3e" - integrity sha512-M/Br8/HYvl7x2oLnme4NxdQwoyV0XUyOWiCyvPp7q1HUTB684lhJf1MikZVrcSjsh2L1rpyi3GRbKdf4cpJWvw== +medusa-test-utils@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.0.11.tgz#bae901efa90426fb64818de700dc6e163820160f" + integrity sha512-CSNb70sXOfKTndzxWtPMYq+KeYaFkSLAPUyhuwDNVvkbne0faOYF5OMCV8r3aLoQ3D1Tvjrb/XB9Qt17ycjuPw== dependencies: + "@babel/plugin-transform-classes" "^7.9.5" + medusa-core-utils "^1.0.10" mongoose "^5.8.0" memory-pager@^1.0.2: diff --git a/packages/medusa-plugin-brightpearl/README.md b/packages/medusa-plugin-brightpearl/README.md index 3a9a3e703a..f200b6af21 100644 --- a/packages/medusa-plugin-brightpearl/README.md +++ b/packages/medusa-plugin-brightpearl/README.md @@ -11,6 +11,7 @@ Sends orders to Brightpearl, listens for stock movements, handles returns. event_owner: [the id of the user who will own goods out events] (required), warehouse: [the warehouse id to allocate orders from] (required) default_status_id: [the status id to assign new orders with] (optional: defaults to 1) + swap_status_id: [the status id to assign new swaps] (optional: defaults to 1) payment_method_code: [the method code to register payments with] (optional: defaults to 1220) sales_account_code: [nominal code to assign line items to] (optional: defaults to 4000) shipping_account_code: [nominal code to assign shipping line to] (optional: defaults to 4040) diff --git a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js index 8cf1395404..08d3572ff3 100644 --- a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js +++ b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js @@ -10,6 +10,7 @@ class BrightpearlService extends BaseService { productVariantService, regionService, orderService, + swapService, discountService, }, options @@ -23,6 +24,7 @@ class BrightpearlService extends BaseService { this.totalsService_ = totalsService this.discountService_ = discountService this.oauthService_ = oauthService + this.swapService_ = swapService } async getClient() { @@ -456,10 +458,9 @@ class BrightpearlService extends BaseService { .create(order) .then(async (salesOrderId) => { const order = await client.orders.retrieve(salesOrderId) - const resResult = await client.warehouses.createReservation( - order, - this.options.warehouse - ) + await client.warehouses + .createReservation(order, this.options.warehouse) + .catch(() => {}) return salesOrderId }) .then((salesOrderId) => { @@ -471,6 +472,190 @@ class BrightpearlService extends BaseService { }) } + async createSwapPayment(fromSwap) { + const client = await this.getClient() + const soId = + fromSwap.metadata && fromSwap.metadata.brightpearl_sales_order_id + + if (!soId) { + return + } + + const paymentType = "RECEIPT" + const paymentMethod = fromSwap.payment_method + const payment = { + transactionRef: `${paymentMethod._id}.${paymentType}`, // Brightpearl cannot accept an auth and capture with same ref + transactionCode: fromSwap._id, + paymentMethodCode: this.options.payment_method_code || "1220", + orderId: soId, + paymentDate: new Date(), + currencyIsoCode: fromSwap.currency_code, + amountPaid: fromSwap.amount_paid, + paymentType, + } + + await client.payments.create(payment) + } + + async createSwapOrder(fromOrder, fromSwap) { + const client = await this.getClient() + let customer = await this.retrieveCustomerByEmail(fromOrder.email) + + if (!customer) { + customer = await this.createCustomer(fromOrder) + } + + const authData = await this.getAuthData() + + const sIndex = fromOrder.swaps.findIndex((s) => fromSwap._id.equals(s)) + + const { shipping_address } = fromSwap + const order = { + currency: { + code: fromOrder.currency_code, + }, + ref: `${fromOrder.display_id}-S${sIndex + 1}`, + externalRef: `${fromOrder._id}.${fromSwap._id}`, + channelId: this.options.channel_id || `1`, + installedIntegrationInstanceId: authData.installation_instance_id, + statusId: + this.options.swap_status_id || this.options.default_status_id || `3`, + customer: { + id: customer.contactId, + address: { + addressFullName: `${shipping_address.first_name} ${shipping_address.last_name}`, + addressLine1: shipping_address.address_1, + addressLine2: shipping_address.address_2, + postalCode: shipping_address.postal_code, + countryIsoCode: shipping_address.country_code, + telephone: shipping_address.phone, + email: fromOrder.email, + }, + }, + delivery: { + shippingMethodId: 0, + address: { + addressFullName: `${shipping_address.first_name} ${shipping_address.last_name}`, + addressLine1: shipping_address.address_1, + addressLine2: shipping_address.address_2, + postalCode: shipping_address.postal_code, + countryIsoCode: shipping_address.country_code, + telephone: shipping_address.phone, + email: fromOrder.email, + }, + }, + rows: await this.getBrightpearlRows({ + region_id: fromOrder.region_id, + discounts: [], + tax_rate: fromOrder.tax_rate, + items: fromSwap.additional_items, + shipping_methods: fromSwap.shipping_methods, + return_shipping: fromSwap.return_shipping, + }), + } + + return client.orders.create(order).then(async (salesOrderId) => { + const order = await client.orders.retrieve(salesOrderId) + await client.warehouses.createReservation(order, this.options.warehouse) + + const paymentMethod = fromOrder.payment_method + const paymentType = "RECEIPT" + const payment = { + transactionRef: `${paymentMethod._id}.${paymentType}-${fromSwap._id}`, + transactionCode: fromOrder._id, + paymentMethodCode: this.options.payment_method_code || "1220", + orderId: salesOrderId, + currencyIsoCode: fromOrder.currency_code, + amountPaid: fromSwap.return.refund_amount, + paymentDate: new Date(), + paymentType, + } + + await client.payments.create(payment) + + return this.swapService_.setMetadata( + fromSwap._id, + "brightpearl_sales_order_id", + salesOrderId + ) + }) + } + + async createSwapCredit(fromOrder, fromSwap) { + const region = await this.regionService_.retrieve(fromOrder.region_id) + const client = await this.getClient() + const authData = await this.getAuthData() + const orderId = fromOrder.metadata.brightpearl_sales_order_id + const sIndex = fromOrder.swaps.findIndex((s) => fromSwap._id.equals(s)) + + if (orderId) { + const parentSo = await client.orders.retrieve(orderId) + const order = { + currency: parentSo.currency, + ref: `${parentSo.ref}-S${sIndex + 1}`, + externalRef: `${parentSo.externalRef}.${fromSwap._id}`, + channelId: this.options.channel_id || `1`, + installedIntegrationInstanceId: authData.installation_instance_id, + customer: parentSo.customer, + delivery: parentSo.delivery, + parentId: orderId, + rows: fromSwap.return.items.map((i) => { + const parentRow = parentSo.rows.find((row) => { + return row.externalRef === i.item_id + }) + return { + net: this.totalsService_.rounded( + (parentRow.net / parentRow.quantity) * i.quantity + ), + tax: this.totalsService_.rounded( + (parentRow.tax / parentRow.quantity) * i.quantity + ), + productId: parentRow.productId, + taxCode: parentRow.taxCode, + externalRef: parentRow.externalRef, + nominalCode: parentRow.nominalCode, + quantity: i.quantity, + } + }), + } + + if (fromSwap.return_shipping && fromSwap.return_shipping.price) { + order.rows.push({ + name: "Return Shipping", + quantity: 1, + taxCode: region.tax_code, + net: -1 * fromSwap.return_shipping.price, + tax: -1 * fromSwap.return_shipping.price * fromOrder.tax_rate, + nominalCode: this.options.shipping_account_code || "4040", + }) + } + + const total = order.rows.reduce((acc, next) => { + return acc + next.net + next.tax + }, 0) + + return client.orders + .createCredit(order) + .then(async (creditId) => { + const paymentMethod = fromOrder.payment_method + const paymentType = "PAYMENT" + const payment = { + transactionRef: `${paymentMethod._id}.${paymentType}-${fromSwap._id}`, + transactionCode: fromSwap._id, + paymentMethodCode: this.options.payment_method_code || "1220", + orderId: creditId, + currencyIsoCode: fromSwap.currency_code, + amountPaid: total, + paymentDate: new Date(), + paymentType, + } + + await client.payments.create(payment) + }) + .catch((err) => console.log(err.response.data.errors)) + } + } + async createPayment(fromOrder) { const client = await this.getClient() const soId = @@ -484,7 +669,7 @@ class BrightpearlService extends BaseService { const payment = { transactionRef: `${paymentMethod._id}.${paymentType}`, // Brightpearl cannot accept an auth and capture with same ref transactionCode: fromOrder._id, - paymentMethodCode: "1220", + paymentMethodCode: this.options.payment_method_code || "1220", orderId: soId, paymentDate: new Date(), currencyIsoCode: fromOrder.currency_code, @@ -570,6 +755,7 @@ class BrightpearlService extends BaseService { nominalCode: this.options.shipping_account_code || "4040", }) } + return lines } @@ -630,6 +816,16 @@ class BrightpearlService extends BaseService { }) .filter((i) => !!i) + // Orders with a concatenated externalReference are swap orders + const [orderId, swapId] = order.externalRef.split(".") + + if (swapId) { + const order = await this.orderService_.retrieve(orderId) + return this.swapService_.createFulfillment(order, swapId, { + goods_out_note: id, + }) + } + return this.orderService_.createFulfillment(order.externalRef, lines, { goods_out_note: id, }) diff --git a/packages/medusa-plugin-brightpearl/src/subscribers/order.js b/packages/medusa-plugin-brightpearl/src/subscribers/order.js index d3c08b8139..e26c5499c8 100644 --- a/packages/medusa-plugin-brightpearl/src/subscribers/order.js +++ b/packages/medusa-plugin-brightpearl/src/subscribers/order.js @@ -1,7 +1,13 @@ class OrderSubscriber { - constructor({ eventBusService, orderService, brightpearlService }) { + constructor({ + eventBusService, + orderService, + swapService, + brightpearlService, + }) { this.orderService_ = orderService this.brightpearlService_ = brightpearlService + this.swapService_ = swapService eventBusService.subscribe("order.refund_created", this.registerRefund) @@ -15,6 +21,11 @@ class OrderSubscriber { ) eventBusService.subscribe("order.shipment_created", this.registerShipment) + eventBusService.subscribe("swap.shipment_created", this.registerShipment) + + // Before we initiate a swap we wait for the payment and the return + eventBusService.subscribe("swap.payment_completed", this.registerSwap) + eventBusService.subscribe("order.swap_received", this.registerSwap) } sendToBrightpearl = (order) => { @@ -25,8 +36,38 @@ class OrderSubscriber { return this.brightpearlService_.createPayment(order) } + registerSwap = async (data) => { + const { order, swap, swap_id } = data + + if (!order && !swap) { + return + } + + let fromOrder = order + if (!fromOrder) { + fromOrder = await this.orderService_.retrieve(swap.order_id) + } + + let fromSwap + if (swap) { + fromSwap = await this.swapService_.retrieve(swap._id) + } else { + fromSwap = await this.swapService_.retrieve(swap_id) + } + + if (!(fromSwap.is_paid && fromSwap.return.status === "received")) { + return + } + + await this.brightpearlService_.createSwapCredit(fromOrder, fromSwap) + await this.brightpearlService_.createSwapOrder(fromOrder, fromSwap) + + const paySwap = await this.swapService_.retrieve(fromSwap._id) + await this.brightpearlService_.createSwapPayment(paySwap) + } + registerShipment = async (data) => { - const { order_id, shipment } = data + const { shipment } = data const noteId = shipment.metadata.goods_out_note if (noteId) { await this.brightpearlService_.registerGoodsOutTrackingNumber( diff --git a/packages/medusa/src/api/routes/admin/customers/get-customer.js b/packages/medusa/src/api/routes/admin/customers/get-customer.js index 5ca5b26b32..7f1615635e 100644 --- a/packages/medusa/src/api/routes/admin/customers/get-customer.js +++ b/packages/medusa/src/api/routes/admin/customers/get-customer.js @@ -1,6 +1,7 @@ export default async (req, res) => { const { id } = req.params try { + const orderService = req.scope.resolve("orderService") const customerService = req.scope.resolve("customerService") let customer = await customerService.retrieve(id) customer = await customerService.decorate( @@ -12,9 +13,14 @@ export default async (req, res) => { "shipping_addresses", "phone", ], - ["orders"] + [] ) + customer.orders = await Promise.all(customer.orders.map(async oId => { + const order = await orderService.retrieve(oId) + return orderService.decorate(order, [], []) + })) + res.json({ customer }) } catch (err) { throw err diff --git a/packages/medusa/src/api/routes/admin/customers/update-customer.js b/packages/medusa/src/api/routes/admin/customers/update-customer.js index 458de02f47..e602de9518 100644 --- a/packages/medusa/src/api/routes/admin/customers/update-customer.js +++ b/packages/medusa/src/api/routes/admin/customers/update-customer.js @@ -16,11 +16,18 @@ export default async (req, res) => { } try { + const orderService = req.scope.resolve("orderService") const customerService = req.scope.resolve("customerService") await customerService.update(id, value) const customer = await customerService.retrieve(id) const data = await customerService.decorate(customer) + data.orders = await Promise.all( + customer.orders.map(async oId => { + const order = await orderService.retrieve(oId) + return orderService.decorate(order, [], []) + }) + ) res.status(200).json({ customer: data }) } catch (err) { throw err diff --git a/packages/medusa/src/api/routes/admin/orders/archive-order.js b/packages/medusa/src/api/routes/admin/orders/archive-order.js index 021fe7d30a..f788035f68 100644 --- a/packages/medusa/src/api/routes/admin/orders/archive-order.js +++ b/packages/medusa/src/api/routes/admin/orders/archive-order.js @@ -4,7 +4,11 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") let order = await orderService.archive(id) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) 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 index 8372b7dade..3ef00cffa7 100644 --- a/packages/medusa/src/api/routes/admin/orders/cancel-order.js +++ b/packages/medusa/src/api/routes/admin/orders/cancel-order.js @@ -4,7 +4,11 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") let order = await orderService.cancel(id) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) 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 index c96d337335..d78ccf7c1a 100644 --- a/packages/medusa/src/api/routes/admin/orders/capture-payment.js +++ b/packages/medusa/src/api/routes/admin/orders/capture-payment.js @@ -4,7 +4,11 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") let order = await orderService.capturePayment(id) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.json({ order }) } catch (error) { throw error diff --git a/packages/medusa/src/api/routes/admin/orders/capture-swap.js b/packages/medusa/src/api/routes/admin/orders/capture-swap.js new file mode 100644 index 0000000000..25085d5f2e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/capture-swap.js @@ -0,0 +1,23 @@ +export default async (req, res) => { + const { id, swap_id } = req.params + + try { + const orderService = req.scope.resolve("orderService") + const swapService = req.scope.resolve("swapService") + + const order = await orderService.retrieve(id) + + await swapService.capturePayment(swap_id) + + // Decorate the order + const data = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) + + res.json({ order: data }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/complete-order.js b/packages/medusa/src/api/routes/admin/orders/complete-order.js index e274fc10a6..d3a3ad03d9 100644 --- a/packages/medusa/src/api/routes/admin/orders/complete-order.js +++ b/packages/medusa/src/api/routes/admin/orders/complete-order.js @@ -4,7 +4,7 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") let order = await orderService.completeOrder(id) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate(order, [], ["region", "customer", "swaps"]) res.json({ order }) } catch (error) { console.log(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 index 613592ea35..3e90c0e551 100644 --- a/packages/medusa/src/api/routes/admin/orders/create-fulfillment.js +++ b/packages/medusa/src/api/routes/admin/orders/create-fulfillment.js @@ -26,8 +26,14 @@ export default async (req, res) => { value.items, value.metadata ) - order = await orderService.decorate(order, [], ["region"]) - res.json({ order }) + + const data = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) + + res.json({ order: data }) } catch (error) { throw error } diff --git a/packages/medusa/src/api/routes/admin/orders/create-shipment.js b/packages/medusa/src/api/routes/admin/orders/create-shipment.js index 3c3b00f7da..b29e4c0901 100644 --- a/packages/medusa/src/api/routes/admin/orders/create-shipment.js +++ b/packages/medusa/src/api/routes/admin/orders/create-shipment.js @@ -22,7 +22,11 @@ export default async (req, res) => { value.fulfillment_id, value.tracking_numbers ) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.json({ order }) } catch (error) { throw error diff --git a/packages/medusa/src/api/routes/admin/orders/create-swap-shipment.js b/packages/medusa/src/api/routes/admin/orders/create-swap-shipment.js new file mode 100644 index 0000000000..b6a4cc672c --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/create-swap-shipment.js @@ -0,0 +1,41 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { id, swap_id } = req.params + + const schema = Validator.object().keys({ + fulfillment_id: Validator.string().required(), + tracking_numbers: Validator.array() + .items(Validator.string()) + .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 swapService = req.scope.resolve("swapService") + + const order = await orderService.retrieve(id) + + await swapService.createShipment( + swap_id, + value.fulfillment_id, + value.tracking_numbers + ) + + // Decorate the order + const data = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) + + res.json({ order: data }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/create-swap.js b/packages/medusa/src/api/routes/admin/orders/create-swap.js new file mode 100644 index 0000000000..095829f3c3 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/create-swap.js @@ -0,0 +1,67 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + return_items: Validator.array() + .items({ + item_id: Validator.string().required(), + quantity: Validator.number().required(), + }) + .required(), + return_shipping: Validator.object() + .keys({ + id: Validator.string().optional(), + price: Validator.number().optional(), + }) + .optional(), + additional_items: Validator.array().items({ + variant_id: Validator.string().required(), + quantity: Validator.number().required(), + }), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const orderService = req.scope.resolve("orderService") + const swapService = req.scope.resolve("swapService") + + let order = await orderService.retrieve(id) + + // Phase 1: Create swap and add it to the order + const swap = await swapService.create( + order, + value.return_items, + value.additional_items, + value.return_shipping + ) + + await orderService.registerSwapCreated(id, swap._id) + + // --> swap_created + // Phase 2: Create a return request from the swap + await swapService.requestReturn(order, swap._id) + + // --> return_request_created + // Phase 3: Create a cart that can be used to pay for the swap difference + await swapService.createCart(order, swap._id) + + // --> finished + + order = await orderService.retrieve(id) + const data = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) + + res.status(200).json({ order: data }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/fulfill-swap.js b/packages/medusa/src/api/routes/admin/orders/fulfill-swap.js new file mode 100644 index 0000000000..a619ecba32 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/fulfill-swap.js @@ -0,0 +1,36 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { id, swap_id } = req.params + + const schema = Validator.object().keys({ + 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 swapService = req.scope.resolve("swapService") + + // Fetch the order + const order = await orderService.retrieve(id) + + // Receive the return + await swapService.createFulfillment(order, swap_id, value.metadata) + + // Decorate the order + const data = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) + + res.status(200).json({ order: data }) + } 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 index 3523bef558..5e224c9e0d 100644 --- a/packages/medusa/src/api/routes/admin/orders/get-order.js +++ b/packages/medusa/src/api/routes/admin/orders/get-order.js @@ -6,11 +6,11 @@ export default async (req, res) => { const customerService = req.scope.resolve("customerService") let order = await orderService.retrieve(id) - order = await orderService.decorate(order, [], ["region", "customer"]) - - if (order.customer_id) { - order.customer = await customerService.retrieve(order.customer_id) - } + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.json({ order }) } catch (error) { diff --git a/packages/medusa/src/api/routes/admin/orders/index.js b/packages/medusa/src/api/routes/admin/orders/index.js index 4f41c8805c..5625716f02 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.js +++ b/packages/medusa/src/api/routes/admin/orders/index.js @@ -95,6 +95,43 @@ export default app => { middlewares.wrap(require("./archive-order").default) ) + /** + * Creates a swap, requests a return and prepares a cart for payment. + */ + route.post("/:id/swaps", middlewares.wrap(require("./create-swap").default)) + + /** + * Receives the inventory to return from a swap + */ + route.post( + "/:id/swaps/:swap_id/receive", + middlewares.wrap(require("./receive-swap").default) + ) + + /** + * Fulfills a swap. + */ + route.post( + "/:id/swaps/:swap_id/fulfillments", + middlewares.wrap(require("./fulfill-swap").default) + ) + + /** + * Marks a swap fulfillment as shipped. + */ + route.post( + "/:id/swaps/:swap_id/shipments", + middlewares.wrap(require("./create-swap-shipment").default) + ) + + /** + * Captures the payment associated with a swap + */ + route.post( + "/:id/swaps/:swap_id/capture", + middlewares.wrap(require("./capture-swap").default) + ) + /** * Set metadata key / value pair. */ diff --git a/packages/medusa/src/api/routes/admin/orders/receive-return.js b/packages/medusa/src/api/routes/admin/orders/receive-return.js index 3b936a5da3..232a10883e 100644 --- a/packages/medusa/src/api/routes/admin/orders/receive-return.js +++ b/packages/medusa/src/api/routes/admin/orders/receive-return.js @@ -25,14 +25,18 @@ export default async (req, res) => { if (typeof value.refund !== "undefined" && value.refund < 0) { refundAmount = 0 } - let order = await orderService.return( + let order = await orderService.receiveReturn( id, return_id, value.items, refundAmount, true ) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.status(200).json({ order }) } catch (err) { diff --git a/packages/medusa/src/api/routes/admin/orders/receive-swap.js b/packages/medusa/src/api/routes/admin/orders/receive-swap.js new file mode 100644 index 0000000000..6a6ae283c5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/receive-swap.js @@ -0,0 +1,44 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { id, swap_id } = req.params + + const schema = Validator.object().keys({ + items: Validator.array() + .items({ + item_id: Validator.string().required(), + quantity: Validator.number().required(), + }) + .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 swapService = req.scope.resolve("swapService") + + // Fetch the order + let order = await orderService.retrieve(id) + + // Receive the return + await swapService.receiveReturn(order, swap_id, value.items) + + // Register swap reception + order = await orderService.registerSwapReceived(id, swap_id) + + // Decorate the order + const data = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) + + res.status(200).json({ order: data }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/refund-payment.js b/packages/medusa/src/api/routes/admin/orders/refund-payment.js index 549ecf3db2..7ae16b9cac 100644 --- a/packages/medusa/src/api/routes/admin/orders/refund-payment.js +++ b/packages/medusa/src/api/routes/admin/orders/refund-payment.js @@ -23,7 +23,11 @@ export default async (req, res) => { value.reason, value.note ) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.status(200).json({ order }) } catch (err) { diff --git a/packages/medusa/src/api/routes/admin/orders/request-return.js b/packages/medusa/src/api/routes/admin/orders/request-return.js index cc173cecbf..7066abdba1 100644 --- a/packages/medusa/src/api/routes/admin/orders/request-return.js +++ b/packages/medusa/src/api/routes/admin/orders/request-return.js @@ -66,7 +66,11 @@ export default async (req, res) => { ) } - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.status(200).json({ order }) } catch (err) { diff --git a/packages/medusa/src/api/routes/admin/orders/set-metadata.js b/packages/medusa/src/api/routes/admin/orders/set-metadata.js index 51c6c02c18..c0ad8ed165 100644 --- a/packages/medusa/src/api/routes/admin/orders/set-metadata.js +++ b/packages/medusa/src/api/routes/admin/orders/set-metadata.js @@ -16,7 +16,11 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") let order = await orderService.setMetadata(id, value.key, value.value) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.status(200).json({ order }) } catch (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 index c65223a11f..73f3392620 100644 --- a/packages/medusa/src/api/routes/admin/orders/update-order.js +++ b/packages/medusa/src/api/routes/admin/orders/update-order.js @@ -32,7 +32,11 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") let order = await orderService.update(id, value) - order = await orderService.decorate(order, [], ["region"]) + order = await orderService.decorate( + order, + [], + ["region", "customer", "swaps"] + ) res.status(200).json({ order }) } catch (err) { diff --git a/packages/medusa/src/api/routes/admin/products/list-products.js b/packages/medusa/src/api/routes/admin/products/list-products.js index a31c2582d1..0074b5d19a 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.js +++ b/packages/medusa/src/api/routes/admin/products/list-products.js @@ -2,6 +2,7 @@ import _ from "lodash" export default async (req, res) => { try { + const variantService = req.scope.resolve("productVariantService") const productService = req.scope.resolve("productService") const queryBuilderService = req.scope.resolve("queryBuilderService") @@ -14,6 +15,22 @@ export default async (req, res) => { query.is_giftcard = req.query.is_giftcard === "true" } + let variantMatches = [] + if ("q" in req.query) { + let textQ = req.query.q + variantMatches = await variantService.list({ + $or: [ + { sku: new RegExp(textQ, "i") }, + { title: new RegExp(textQ, "i") }, + ], + }) + + query.$or = [ + ...query.$or, + { variants: { $in: variantMatches.map(({ _id }) => _id.toString()) } }, + ] + } + const limit = parseInt(req.query.limit) || 0 const offset = parseInt(req.query.offset) || 0 diff --git a/packages/medusa/src/api/routes/admin/store/update-store.js b/packages/medusa/src/api/routes/admin/store/update-store.js index 9915025021..80a2d9da06 100644 --- a/packages/medusa/src/api/routes/admin/store/update-store.js +++ b/packages/medusa/src/api/routes/admin/store/update-store.js @@ -3,6 +3,7 @@ import { MedusaError, Validator } from "medusa-core-utils" export default async (req, res) => { const schema = Validator.object().keys({ name: Validator.string(), + swap_link_template: Validator.string(), default_currency: Validator.string(), currencies: Validator.array().items(Validator.string()), }) diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index ac33ad0290..582f218335 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -10,6 +10,7 @@ import orderRoutes from "./orders" import customerRoutes from "./customers" import shippingOptionRoutes from "./shipping-options" import regionRoutes from "./regions" +import swapRoutes from "./swaps" const route = Router() @@ -33,6 +34,7 @@ export default (app, container, config) => { cartRoutes(route, container) shippingOptionRoutes(route) regionRoutes(route) + swapRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/store/swaps/create-swap.js b/packages/medusa/src/api/routes/store/swaps/create-swap.js new file mode 100644 index 0000000000..4453552f3e --- /dev/null +++ b/packages/medusa/src/api/routes/store/swaps/create-swap.js @@ -0,0 +1,26 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const schema = Validator.object().keys({ + cart_id: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + const swapService = req.scope.resolve("swapService") + + try { + const swap = await swapService.retrieveByCartId(value.cart_id) + const data = await swapService.registerCartCompletion( + swap._id, + value.cart_id + ) + + res.status(200).json({ swap: data }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/swaps/get-swap-by-cart.js b/packages/medusa/src/api/routes/store/swaps/get-swap-by-cart.js new file mode 100644 index 0000000000..41ea0cf0a8 --- /dev/null +++ b/packages/medusa/src/api/routes/store/swaps/get-swap-by-cart.js @@ -0,0 +1,11 @@ +export default async (req, res) => { + const { cart_id } = req.params + + try { + const swapService = req.scope.resolve("swapService") + const swap = await swapService.retrieveByCartId(cart_id) + res.json({ swap }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/store/swaps/index.js b/packages/medusa/src/api/routes/store/swaps/index.js new file mode 100644 index 0000000000..944f60aa35 --- /dev/null +++ b/packages/medusa/src/api/routes/store/swaps/index.js @@ -0,0 +1,16 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/swaps", route) + + route.get( + "/:cart_id", + middlewares.wrap(require("./get-swap-by-cart").default) + ) + route.post("/", middlewares.wrap(require("./create-swap").default)) + + return app +} diff --git a/packages/medusa/src/models/__mocks__/order.js b/packages/medusa/src/models/__mocks__/order.js index 0ae717af16..a6b54bce97 100644 --- a/packages/medusa/src/models/__mocks__/order.js +++ b/packages/medusa/src/models/__mocks__/order.js @@ -74,6 +74,7 @@ export const orders = { processedOrder: { _id: IdMap.getId("processed-order"), email: "oliver@test.dk", + tax_rate: 0, billing_address: { first_name: "Oli", last_name: "Medusa", diff --git a/packages/medusa/src/models/cart.js b/packages/medusa/src/models/cart.js index c639685bd3..ed52c250ed 100644 --- a/packages/medusa/src/models/cart.js +++ b/packages/medusa/src/models/cart.js @@ -29,6 +29,8 @@ class CartModel extends BaseModel { shipping_options: { type: [ShippingMethodSchema], default: [] }, payment_method: { type: PaymentMethodSchema }, shipping_methods: { type: [ShippingMethodSchema], default: [] }, + is_swap: { type: Boolean, default: false }, + created: { type: String, default: Date.now }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } } diff --git a/packages/medusa/src/models/order.js b/packages/medusa/src/models/order.js index 97d0ba1a35..2efbef7931 100644 --- a/packages/medusa/src/models/order.js +++ b/packages/medusa/src/models/order.js @@ -6,7 +6,6 @@ import PaymentMethodSchema from "./schemas/payment-method" import ShippingMethodSchema from "./schemas/shipping-method" import AddressSchema from "./schemas/address" import DiscountSchema from "./schemas/discount" -import ShipmentSchema from "./schemas/shipment" import ReturnSchema from "./schemas/return" import RefundSchema from "./schemas/refund" import FulfillmentSchema from "./schemas/fulfillment" @@ -35,8 +34,9 @@ class OrderModel extends BaseModel { region_id: { type: String, required: true }, discounts: { type: [DiscountSchema], default: [] }, customer_id: { type: String }, - payment_method: { type: PaymentMethodSchema, default: {} }, - shipping_methods: { type: [ShippingMethodSchema], default: [] }, + payment_method: { type: PaymentMethodSchema, required: true }, + shipping_methods: { type: [ShippingMethodSchema], required: true }, + swaps: { type: [String], default: [] }, 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/return-line-item.js b/packages/medusa/src/models/schemas/return-line-item.js index 67b621fad9..bfceac0219 100644 --- a/packages/medusa/src/models/schemas/return-line-item.js +++ b/packages/medusa/src/models/schemas/return-line-item.js @@ -13,7 +13,7 @@ import mongoose from "mongoose" * @property {Object} metadata */ export default new mongoose.Schema({ - item_id: { type: String, required: true, unique: true }, + item_id: { type: String, required: true }, content: { type: mongoose.Schema.Types.Mixed, required: true }, quantity: { type: Number, required: true }, is_requested: { type: Boolean, required: true }, diff --git a/packages/medusa/src/models/store.js b/packages/medusa/src/models/store.js index 07a4ca0b5c..0e1f7aa967 100644 --- a/packages/medusa/src/models/store.js +++ b/packages/medusa/src/models/store.js @@ -9,6 +9,7 @@ class StoreModel extends BaseModel { currencies: { type: [String], default: [] }, payment_providers: { type: [String], default: [] }, fulfillment_providers: { type: [String], default: [] }, + swap_link_template: { type: String }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } } diff --git a/packages/medusa/src/models/swap.js b/packages/medusa/src/models/swap.js new file mode 100644 index 0000000000..0599b53634 --- /dev/null +++ b/packages/medusa/src/models/swap.js @@ -0,0 +1,35 @@ +import mongoose from "mongoose" +import { BaseModel } from "medusa-interfaces" + +import LineItemSchema from "./schemas/line-item" +import ReturnSchema from "./schemas/return" +import FulfillmentSchema from "./schemas/fulfillment" +import PaymentMethodSchema from "./schemas/payment-method" +import ShippingMethodSchema from "./schemas/shipping-method" +import AddressSchema from "./schemas/address" + +class SwapModel extends BaseModel { + static modelName = "Swap" + static schema = { + fulfillment_status: { type: String, default: "not_fulfilled" }, + payment_status: { type: String, default: "awaiting" }, + is_paid: { type: Boolean, default: false }, + return: { type: ReturnSchema }, + return_items: { type: [mongoose.Schema.Types.Mixed], required: true }, + return_shipping: { type: mongoose.Schema.Types.Mixed }, + fulfillments: { type: [FulfillmentSchema] }, + additional_items: { type: [LineItemSchema], required: true }, + payment_method: { type: PaymentMethodSchema }, + shipping_methods: { type: [ShippingMethodSchema] }, + shipping_address: { type: AddressSchema }, + amount_paid: { type: Number }, + region_id: { type: String }, + currency_code: { type: String }, + order_id: { type: String }, + cart_id: { type: String }, + created: { type: String, default: Date.now }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, + } +} + +export default SwapModel diff --git a/packages/medusa/src/services/__mocks__/order.js b/packages/medusa/src/services/__mocks__/order.js index faeb617895..d7909d3cfa 100644 --- a/packages/medusa/src/services/__mocks__/order.js +++ b/packages/medusa/src/services/__mocks__/order.js @@ -115,6 +115,7 @@ export const orders = { profile_id: IdMap.getId("profile1"), }, ], + tax_rate: 0, fulfillment_status: "fulfilled", payment_status: "captured", status: "completed", diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index fa23e2cdff..7f25b5c01b 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -148,6 +148,58 @@ describe("CartService", () => { region_id: IdMap.getId("testRegion"), }) }) + + it("creates a cart with a prefilled shipping address", async () => { + const res = cartService.create({ + region_id: IdMap.getId("testRegion"), + shipping_address: { + first_name: "LeBron", + last_name: "James", + address_1: "Dunk St", + city: "Dunkville", + province: "CA", + postal_code: "12345", + country_code: "PT", + }, + }) + + await expect(res).rejects.toThrow("Shipping country not in region") + }) + + it("creates a cart with a prefilled shipping address", async () => { + await cartService.create({ + region_id: IdMap.getId("testRegion"), + shipping_address: { + first_name: "LeBron", + last_name: "James", + address_1: "Dunk St", + city: "Dunkville", + province: "CA", + postal_code: "12345", + country_code: "US", + }, + }) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + "cart.created", + expect.any(Object) + ) + + expect(CartModelMock.create).toHaveBeenCalledTimes(1) + expect(CartModelMock.create).toHaveBeenCalledWith({ + region_id: IdMap.getId("testRegion"), + shipping_address: { + first_name: "LeBron", + last_name: "James", + address_1: "Dunk St", + city: "Dunkville", + province: "CA", + postal_code: "12345", + country_code: "US", + }, + }) + }) }) describe("addLineItem", () => { diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index 6c36b18282..d041a645f7 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -2,6 +2,8 @@ import { IdMap } from "medusa-test-utils" import { OrderModelMock, orders } from "../../models/__mocks__/order" import { carts } from "../../models/__mocks__/cart" import OrderService from "../order" +import ReturnService from "../return" +import FulfillmentService from "../fulfillment" import { PaymentProviderServiceMock, DefaultProviderMock, @@ -434,11 +436,15 @@ describe("OrderService", () => { }) describe("createFulfillment", () => { + const fulfillmentService = new FulfillmentService({ + fulfillmentProviderService: FulfillmentProviderServiceMock, + shippingProfileService: ShippingProfileServiceMock, + totalsService: TotalsServiceMock, + }) const orderService = new OrderService({ orderModel: OrderModelMock, paymentProviderService: PaymentProviderServiceMock, - fulfillmentProviderService: FulfillmentProviderServiceMock, - shippingProfileService: ShippingProfileServiceMock, + fulfillmentService, eventBusService: EventBusServiceMock, }) @@ -509,8 +515,7 @@ describe("OrderService", () => { }, quantity: 1, }, - fulfilled_quantity: 10, - fulfilled: true, + fulfilled_quantity: 0, quantity: 10, }, ], @@ -560,9 +565,17 @@ describe("OrderService", () => { }) }) - describe("return", () => { + describe("receiveReturn", () => { + const returnService = new ReturnService({ + totalsService: TotalsServiceMock, + shippingOptionService: ShippingOptionServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) const orderService = new OrderService({ orderModel: OrderModelMock, + returnService, + shippingOptionService: ShippingOptionServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, paymentProviderService: PaymentProviderServiceMock, totalsService: TotalsServiceMock, eventBusService: EventBusServiceMock, @@ -573,7 +586,7 @@ describe("OrderService", () => { }) it("calls order model functions", async () => { - await orderService.return( + await orderService.receiveReturn( IdMap.getId("returned-order"), IdMap.getId("return"), [ @@ -671,7 +684,7 @@ describe("OrderService", () => { }) it("return with custom refund", async () => { - await orderService.return( + await orderService.receiveReturn( IdMap.getId("returned-order"), IdMap.getId("return"), [ @@ -770,7 +783,7 @@ describe("OrderService", () => { }) it("calls order model functions and sets partially_returned", async () => { - await orderService.return( + await orderService.receiveReturn( IdMap.getId("order-refund"), IdMap.getId("return"), [ @@ -882,7 +895,7 @@ describe("OrderService", () => { }) it("sets requires_action on additional items", async () => { - await orderService.return( + await orderService.receiveReturn( IdMap.getId("order-refund"), IdMap.getId("return"), [ @@ -930,17 +943,17 @@ describe("OrderService", () => { expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) expect(OrderModelMock.updateOne).toHaveBeenCalledWith( - { _id: IdMap.getId("order-refund") }, + { _id: IdMap.getId("order-refund"), "returns._id": originalReturn._id }, { $set: { - returns: [toSet], + "returns.$": toSet, }, } ) }) it("sets requires_action on unmatcing quantities", async () => { - await orderService.return( + await orderService.receiveReturn( IdMap.getId("order-refund"), IdMap.getId("return"), [ @@ -966,10 +979,10 @@ describe("OrderService", () => { expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1) expect(OrderModelMock.updateOne).toHaveBeenCalledWith( - { _id: IdMap.getId("order-refund") }, + { _id: IdMap.getId("order-refund"), "returns._id": originalReturn._id }, { $set: { - returns: [toSet], + "returns.$": toSet, }, } ) @@ -977,8 +990,14 @@ describe("OrderService", () => { }) describe("requestReturn", () => { + const returnService = new ReturnService({ + totalsService: TotalsServiceMock, + shippingOptionService: ShippingOptionServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) const orderService = new OrderService({ orderModel: OrderModelMock, + returnService, shippingOptionService: ShippingOptionServiceMock, fulfillmentProviderService: FulfillmentProviderServiceMock, paymentProviderService: PaymentProviderServiceMock, @@ -1110,9 +1129,15 @@ describe("OrderService", () => { }) describe("createShipment", () => { + const fulfillmentService = new FulfillmentService({ + fulfillmentProviderService: FulfillmentProviderServiceMock, + shippingProfileService: ShippingProfileServiceMock, + totalsService: TotalsServiceMock, + }) const orderService = new OrderService({ orderModel: OrderModelMock, fulfillmentProviderService: FulfillmentProviderServiceMock, + fulfillmentService, eventBusService: EventBusServiceMock, }) @@ -1136,37 +1161,34 @@ describe("OrderService", () => { }, { $set: { - "fulfillments.$": [ - { - _id: IdMap.getId("fulfillment"), - provider_id: "default_provider", - tracking_numbers: ["1234", "2345"], - data: {}, - items: [ - { - _id: IdMap.getId("existingLine"), - content: { - product: { - _id: IdMap.getId("validId"), - }, - quantity: 1, - unit_price: 123, - variant: { - _id: IdMap.getId("can-cover"), - }, + "fulfillments.$": { + _id: IdMap.getId("fulfillment"), + provider_id: "default_provider", + tracking_numbers: ["1234", "2345"], + data: {}, + 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", - fulfilled_quantity: 10, - shipped_quantity: 10, - quantity: 10, - thumbnail: "test-img-yeah.com/thumb", - title: "merge line", }, - ], - shipped_at: expect.anything(), - metadata: {}, - }, - ], + description: "This is a new line", + fulfilled_quantity: 10, + quantity: 10, + thumbnail: "test-img-yeah.com/thumb", + title: "merge line", + }, + ], + shipped_at: expect.anything(), + metadata: {}, + }, items: [ { _id: IdMap.getId("existingLine"), @@ -1181,6 +1203,7 @@ describe("OrderService", () => { }, }, description: "This is a new line", + shipped: true, fulfilled_quantity: 10, shipped_quantity: 10, quantity: 10, @@ -1203,6 +1226,191 @@ describe("OrderService", () => { }) }) + describe("registerSwapCreated", () => { + beforeEach(async () => { + jest.clearAllMocks() + }) + const orderModel = { + findOne: jest + .fn() + .mockReturnValue(Promise.resolve({ _id: IdMap.getId("order") })), + updateOne: jest.fn().mockReturnValue(Promise.resolve()), + } + + it("adds a swap to an order", async () => { + const swapService = { + retrieve: jest + .fn() + .mockReturnValue( + Promise.resolve({ _id: "1235", order_id: IdMap.getId("order") }) + ), + } + const orderService = new OrderService({ + swapService, + orderModel, + eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) }, + }) + + const res = orderService.registerSwapCreated(IdMap.getId("order"), "1235") + expect(res).resolves + + await res + expect(orderModel.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("order"), + }, + { + $addToSet: { swaps: "1235" }, + } + ) + }) + + it("fails if order/swap relationship is not satisfied", async () => { + const swapService = { + retrieve: jest + .fn() + .mockReturnValue( + Promise.resolve({ _id: "1235", order_id: IdMap.getId("order_1") }) + ), + } + const orderService = new OrderService({ + swapService, + orderModel, + eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) }, + }) + + const res = orderService.registerSwapCreated(IdMap.getId("order"), "1235") + expect(res).rejects.toThrow("Swap must belong to the given order") + }) + }) + + describe("registerSwapReceived", () => { + beforeEach(async () => { + jest.clearAllMocks() + }) + const orderModel = { + findOne: jest + .fn() + .mockReturnValue(Promise.resolve({ _id: IdMap.getId("order") })), + updateOne: jest.fn().mockReturnValue(Promise.resolve()), + } + + it("fails if order/swap relationship not satisfied", async () => { + const swapService = { + retrieve: jest + .fn() + .mockReturnValue( + Promise.resolve({ _id: "1235", order_id: IdMap.getId("order_1") }) + ), + } + const orderService = new OrderService({ + swapService, + orderModel, + eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) }, + }) + + const res = orderService.registerSwapReceived( + IdMap.getId("order"), + "1235" + ) + await expect(res).rejects.toThrow("Swap must belong to the given order") + }) + + it("fails if swap doesn't have status received", async () => { + const swapService = { + retrieve: jest.fn().mockReturnValue( + Promise.resolve({ + _id: "1235", + order_id: IdMap.getId("order"), + return: { status: "requested" }, + }) + ), + } + const orderService = new OrderService({ + swapService, + orderModel, + eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) }, + }) + + const res = orderService.registerSwapReceived( + IdMap.getId("order"), + "1235" + ) + await expect(res).rejects.toThrow("Swap is not received") + }) + + it("registers a swap as received", async () => { + const model = { + findOne: jest.fn().mockReturnValue( + Promise.resolve({ + _id: IdMap.getId("order_123"), + items: [ + { + _id: IdMap.getId("1234"), + returned_quantity: 0, + quantity: 1, + }, + ], + }) + ), + updateOne: jest.fn().mockReturnValue(Promise.resolve()), + } + const swapService = { + retrieve: jest.fn().mockReturnValue( + Promise.resolve({ + _id: "1235", + order_id: IdMap.getId("order_123"), + return: { status: "received" }, + return_items: [{ item_id: IdMap.getId("1234"), quantity: 1 }], + }) + ), + } + const orderService = new OrderService({ + swapService, + orderModel: model, + eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) }, + }) + + await orderService.registerSwapReceived(IdMap.getId("order"), "1235") + + expect(model.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("order_123"), + }, + { + $set: { + items: [ + { + _id: IdMap.getId("1234"), + returned_quantity: 1, + returned: true, + quantity: 1, + }, + ], + }, + } + ) + }) + + it("fails if order/swap relationship is not satisfied", async () => { + const swapService = { + retrieve: jest + .fn() + .mockReturnValue( + Promise.resolve({ _id: "1235", order_id: IdMap.getId("order_1") }) + ), + } + const orderService = new OrderService({ + swapService, + orderModel, + eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) }, + }) + + const res = orderService.registerSwapCreated(IdMap.getId("order"), "1235") + expect(res).rejects.toThrow("Swap must belong to the given order") + }) + }) + describe("archive", () => { const orderService = new OrderService({ orderModel: OrderModelMock, diff --git a/packages/medusa/src/services/__tests__/swap.js b/packages/medusa/src/services/__tests__/swap.js new file mode 100644 index 0000000000..5fe21ee4e4 --- /dev/null +++ b/packages/medusa/src/services/__tests__/swap.js @@ -0,0 +1,731 @@ +import { IdMap } from "medusa-test-utils" +import { ProductVariantServiceMock } from "../__mocks__/product-variant" +import { + // FulfillmentProviderServiceMock, + DefaultProviderMock as FulfillmentProviderMock, +} from "../__mocks__/fulfillment-provider" +import SwapService from "../swap" + +const generateOrder = (orderId, items, additional = {}) => { + return { + _id: IdMap.getId(orderId), + items: items.map( + ({ + id, + product_id, + variant_id, + fulfilled, + returned, + quantity, + price, + }) => ({ + _id: IdMap.getId(id), + content: { + product: { + _id: IdMap.getId(product_id), + }, + variant: { + _id: IdMap.getId(variant_id), + }, + unit_price: price, + }, + quantity, + fulfilled_quantity: fulfilled || 0, + returned_quantity: returned || 0, + }) + ), + ...additional, + } +} + +const testOrder = generateOrder( + "test", + [ + { + id: "line", + product_id: "product", + variant_id: "variant", + price: 100, + quantity: 2, + fulfilled: 1, + }, + ], + { + fulfillment_status: "fulfilled", + payment_status: "captured", + currency_code: "DKK", + region_id: IdMap.getId("region"), + tax_rate: 0, + shipping_address: { + first_name: "test", + last_name: "testson", + address_1: "1800 test st", + city: "testville", + province: "test", + country_code: "us", + postal_code: "12345", + phone: "+18001231234", + }, + } +) + +const SwapModel = ({ create, updateOne, findOne } = {}) => { + return { + create: jest.fn().mockImplementation((...args) => { + if (create) { + return create(...args) + } + return Promise.resolve({ data: "swap" }) + }), + updateOne: jest.fn().mockImplementation((...args) => { + if (updateOne) { + return updateOne(...args) + } + return Promise.resolve({ data: "swap" }) + }), + findOne: jest.fn().mockImplementation((...args) => { + if (findOne) { + return findOne(...args) + } + return Promise.resolve({ data: "swap" }) + }), + } +} + +describe("SwapService", () => { + describe("validateReturnItems_", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("fails if item is returned", async () => { + const swapService = new SwapService({}) + const res = () => + swapService.validateReturnItems_( + { + items: [ + { + _id: IdMap.getId("line1"), + quantity: 1, + returned_quantity: 1, + }, + ], + }, + [{ item_id: IdMap.getId("line1"), quantity: 1 }] + ) + + expect(res).toThrow("Cannot return more items than have been ordered") + }) + + it("fails if item is returned", async () => { + const swapService = new SwapService({}) + const res = () => + swapService.validateReturnItems_( + { + items: [ + { + _id: IdMap.getId("line1"), + quantity: 1, + returned_quantity: 1, + }, + ], + }, + [{ item_id: IdMap.getId("line2"), quantity: 1 }] + ) + + expect(res).toThrow("Item does not exist on order") + }) + + it("successfully resolves", async () => { + const swapService = new SwapService({}) + const res = swapService.validateReturnItems_( + { + items: [ + { + _id: IdMap.getId("line1"), + quantity: 1, + returned_quantity: 0, + }, + ], + }, + [{ item_id: IdMap.getId("line1"), quantity: 1 }] + ) + + expect(res).toEqual([{ item_id: IdMap.getId("line1"), quantity: 1 }]) + }) + }) + + describe("createCart", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("success", () => { + const existing = { + _id: IdMap.getId("test-swap"), + order_id: IdMap.getId("test"), + return: { + _id: IdMap.getId("return-swap"), + test: "notreceived", + refund_amount: 11, + }, + return_items: [{ item_id: IdMap.getId("line"), quantity: 1 }], + additional_items: [{ data: "lines" }], + other: "data", + } + + const cartService = { + create: jest + .fn() + .mockReturnValue(Promise.resolve({ _id: IdMap.getId("swap-cart") })), + } + const swapModel = SwapModel({ findOne: () => Promise.resolve(existing) }) + const swapService = new SwapService({ + productVariantService: ProductVariantServiceMock, + swapModel, + cartService, + }) + + it("finds swap and calls return create cart", async () => { + await swapService.createCart(testOrder, IdMap.getId("swap-1")) + + expect(swapModel.findOne).toHaveBeenCalledTimes(1) + expect(swapModel.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("swap-1"), + }) + + expect(cartService.create).toHaveBeenCalledTimes(1) + expect(cartService.create).toHaveBeenCalledWith({ + email: testOrder.email, + shipping_address: testOrder.shipping_address, + billing_address: testOrder.billing_address, + items: [ + { + _id: IdMap.getId("line"), + content: { + variant: { + _id: IdMap.getId("variant"), + }, + product: { + _id: IdMap.getId("product"), + }, + unit_price: -100, + }, + quantity: 1, + fulfilled_quantity: 1, + returned_quantity: 0, + metadata: { + is_return_line: true, + }, + }, + ...existing.additional_items, + ], + region_id: testOrder.region_id, + customer_id: testOrder.customer_id, + is_swap: true, + metadata: { + swap_id: IdMap.getId("test-swap"), + parent_order_id: IdMap.getId("test"), + }, + }) + + expect(swapModel.updateOne).toHaveBeenCalledTimes(1) + expect(swapModel.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("swap-1") }, + { $set: { cart_id: IdMap.getId("swap-cart") } } + ) + }) + }) + + describe("failure", () => { + const existing = { + return: { + _id: IdMap.getId("return-swap"), + test: "notreceived", + refund_amount: 11, + }, + additional_items: [{ data: "lines" }], + other: "data", + } + + it("fails if swap doesn't belong to order", async () => { + const swapModel = SwapModel({ + findOne: () => Promise.resolve(existing), + }) + const swapService = new SwapService({ swapModel }) + const res = swapService.createCart(testOrder, IdMap.getId("swap-1")) + + await expect(res).rejects.toThrow( + "The swap does not belong to the order" + ) + }) + + it("fails if cart already created", async () => { + const swapModel = SwapModel({ + findOne: () => + Promise.resolve({ + ...existing, + order_id: IdMap.getId("test"), + cart_id: IdMap.getId("swap-cart"), + }), + }) + const swapService = new SwapService({ swapModel }) + const res = swapService.createCart(testOrder, IdMap.getId("swap-1")) + + await expect(res).rejects.toThrow( + "A cart has already been created for the swap" + ) + }) + }) + }) + + describe("create", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("success", () => { + const lineItemService = { + generate: jest + .fn() + .mockImplementation((variantId, regionId, quantity, metadata) => { + return { + content: { + unit_price: 100, + variant: { + _id: variantId, + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity, + } + }), + } + const swapModel = SwapModel() + const swapService = new SwapService({ + swapModel, + productVariantService: ProductVariantServiceMock, + lineItemService, + }) + + it("generates lines items", async () => { + await swapService.create( + testOrder, + [{ item_id: IdMap.getId("line"), quantity: 1 }], + [{ variant_id: IdMap.getId("new-variant"), quantity: 1 }], + { + id: IdMap.getId("return-shipping"), + price: 20, + } + ) + + expect(lineItemService.generate).toHaveBeenCalledTimes(1) + expect(lineItemService.generate).toHaveBeenCalledWith( + IdMap.getId("new-variant"), + IdMap.getId("region"), + 1 + ) + }) + + it("creates swap", async () => { + await swapService.create( + testOrder, + [{ item_id: IdMap.getId("line"), quantity: 1 }], + [{ variant_id: IdMap.getId("new-variant"), quantity: 1 }], + { + id: IdMap.getId("return-shipping"), + price: 20, + } + ) + + expect(swapModel.create).toHaveBeenCalledWith({ + order_id: IdMap.getId("test"), + return_items: [{ item_id: IdMap.getId("line"), quantity: 1 }], + region_id: IdMap.getId("region"), + currency_code: "DKK", + return_shipping: { + id: IdMap.getId("return-shipping"), + price: 20, + }, + additional_items: [ + { + content: { + unit_price: 100, + variant: { + _id: IdMap.getId("new-variant"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 1, + }, + ], + }) + }) + }) + }) + + describe("receiveReturn", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("success", () => { + const returnService = { + receiveReturn: jest + .fn() + .mockReturnValue(Promise.resolve({ test: "received" })), + } + + const existing = { + order_id: IdMap.getId("test"), + return: { + _id: IdMap.getId("return-swap"), + test: "notreceived", + refund_amount: 11, + }, + other: "data", + } + + const swapModel = SwapModel({ findOne: () => Promise.resolve(existing) }) + const swapService = new SwapService({ swapModel, returnService }) + + it("calls register return and updates return value", async () => { + await swapService.receiveReturn(testOrder, IdMap.getId("swap"), [ + { variant_id: IdMap.getId("1234"), quantity: 1 }, + ]) + + expect(swapModel.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("swap") }, + { + $set: { + status: "received", + return: { test: "received" }, + }, + } + ) + + expect(returnService.receiveReturn).toHaveBeenCalledTimes(1) + expect(returnService.receiveReturn).toHaveBeenCalledWith( + testOrder, + existing.return, + [{ variant_id: IdMap.getId("1234"), quantity: 1 }], + 11, + false + ) + }) + }) + + describe("failure", () => { + const returnService = { + receiveReturn: jest + .fn() + .mockReturnValue(Promise.resolve({ status: "requires_action" })), + } + + const existing = { + order_id: IdMap.getId("test"), + return: { + _id: IdMap.getId("return-swap"), + test: "notreceived", + refund_amount: 11, + }, + other: "data", + } + + const swapModel = SwapModel({ + findOne: t => + Promise.resolve(t._id.equals(IdMap.getId("empty")) ? {} : existing), + }) + const swapService = new SwapService({ swapModel, returnService }) + + it("fails if swap has no return request", async () => { + const res = swapService.receiveReturn( + testOrder, + IdMap.getId("empty"), + [] + ) + await expect(res).rejects.toThrow("Swap has no return request") + }) + + it("sets requires action if return fails", async () => { + await swapService.receiveReturn(testOrder, IdMap.getId("swap"), [ + { variant_id: IdMap.getId("1234"), quantity: 1 }, + ]) + + expect(swapModel.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("swap"), + }, + { + $set: { + status: "requires_action", + return: { status: "requires_action" }, + }, + } + ) + + expect(returnService.receiveReturn).toHaveBeenCalledTimes(1) + expect(returnService.receiveReturn).toHaveBeenCalledWith( + testOrder, + existing.return, + [{ variant_id: IdMap.getId("1234"), quantity: 1 }], + 11, + false + ) + }) + }) + }) + + describe("createFulfillment", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("success", () => { + const fulfillmentService = { + createFulfillment: jest + .fn() + .mockReturnValue(Promise.resolve([{ data: "new" }])), + } + + const existing = { + additional_items: [ + { + _id: IdMap.getId("1234"), + quantity: 2, + }, + ], + shipping_methods: [{ method: "1" }], + return: { + _id: IdMap.getId("return-swap"), + test: "notreceived", + refund_amount: 11, + }, + other: "data", + } + + const swapModel = SwapModel({ findOne: () => existing }) + const swapService = new SwapService({ swapModel, fulfillmentService }) + + it("creates a fulfillment", async () => { + await swapService.createFulfillment(testOrder, IdMap.getId("swap")) + + expect(swapModel.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("swap"), + }, + { + $set: { + fulfillment_status: "fulfilled", + fulfillments: [{ data: "new" }], + }, + } + ) + + expect(fulfillmentService.createFulfillment).toHaveBeenCalledWith( + { + ...existing, + currency_code: testOrder.currency_code, + tax_rate: testOrder.tax_rate, + region_id: testOrder.region_id, + display_id: testOrder.display_id, + is_swap: true, + billing_address: testOrder.billing_address, + items: existing.additional_items, + shipping_methods: existing.shipping_methods, + }, + [{ item_id: IdMap.getId("1234"), quantity: 2 }], + {} + ) + }) + }) + + describe("failure", () => {}) + }) + + describe("createShipment", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("success", () => { + const fulfillmentService = { + createShipment: jest.fn().mockImplementation((o, f) => { + return Promise.resolve({ ...f, data: "new" }) + }), + } + + const eventBusService = { + emit: jest.fn().mockReturnValue(Promise.resolve()), + } + + const existing = { + additional_items: [ + { + _id: IdMap.getId("1234-1"), + quantity: 2, + shipped_quantity: 0, + }, + ], + fulfillments: [ + { + _id: IdMap.getId("f1"), + items: [ + { + _id: IdMap.getId("1234-1"), + item_id: IdMap.getId("1234-1"), + quantity: 2, + }, + ], + }, + { + _id: IdMap.getId("f2"), + items: [ + { + _id: IdMap.getId("1234-2"), + item_id: IdMap.getId("1234-2"), + quantity: 2, + }, + ], + }, + ], + shipping_methods: [{ method: "1" }], + return: { + _id: IdMap.getId("return-swap"), + test: "notreceived", + refund_amount: 11, + }, + other: "data", + } + const swapModel = SwapModel({ + updateOne: () => Promise.resolve(existing), + findOne: () => existing, + }) + const swapService = new SwapService({ + swapModel, + eventBusService, + fulfillmentService, + }) + + it("creates a shipment", async () => { + await swapService.createShipment( + IdMap.getId("swap"), + IdMap.getId("f1"), + ["1234"], + {} + ) + + expect(swapModel.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("swap"), + }, + { + $set: { + additional_items: [ + { + _id: IdMap.getId("1234-1"), + quantity: 2, + shipped: true, + shipped_quantity: 2, + }, + ], + fulfillment_status: "shipped", + fulfillments: [ + { + _id: IdMap.getId("f1"), + items: [ + { + _id: IdMap.getId("1234-1"), + item_id: IdMap.getId("1234-1"), + quantity: 2, + }, + ], + data: "new", + }, + { + _id: IdMap.getId("f2"), + items: [ + { + _id: IdMap.getId("1234-2"), + item_id: IdMap.getId("1234-2"), + quantity: 2, + }, + ], + }, + ], + }, + } + ) + + expect(fulfillmentService.createShipment).toHaveBeenCalledWith( + { + items: existing.additional_items, + shipping_methods: existing.shipping_methods, + }, + { + _id: IdMap.getId("f1"), + items: [ + { + _id: IdMap.getId("1234-1"), + item_id: IdMap.getId("1234-1"), + quantity: 2, + }, + ], + }, + ["1234"], + {} + ) + }) + }) + + describe("failure", () => {}) + }) + + describe("requestReturn", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("success", () => { + const existing = { + return_items: [{ data: "returnline" }], + return_shipping: { shipping: "return" }, + } + + const returnService = { + requestReturn: jest + .fn() + .mockReturnValue(Promise.resolve({ return: "data" })), + } + const swapModel = SwapModel({ findOne: () => existing }) + const swapService = new SwapService({ + swapModel, + returnService, + }) + + it("calls requestReturn and updates", async () => { + await swapService.requestReturn(testOrder, IdMap.getId("swap")) + + expect(returnService.requestReturn).toHaveBeenCalledTimes(1) + expect(returnService.requestReturn).toHaveBeenCalledWith( + testOrder, + existing.return_items, + existing.return_shipping + ) + + expect(swapModel.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("swap"), + }, + { $set: { return: { return: "data" } } } + ) + }) + }) + }) +}) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index d7ec67c0c9..405dd501c0 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -208,10 +208,19 @@ class CartService extends BaseService { } const region = await this.regionService_.retrieve(region_id) - if (region.countries.length === 1) { - // Preselect the country if the region only has 1 - data.shipping_address = { - country_code: region.countries[0], + if (!data.shipping_address) { + if (region.countries.length === 1) { + // Preselect the country if the region only has 1 + data.shipping_address = { + country_code: region.countries[0], + } + } + } else { + if (!region.countries.includes(data.shipping_address.country_code)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Shipping country not in region" + ) } } @@ -262,7 +271,9 @@ class CartService extends BaseService { if (Array.isArray(item.content)) { item.content.forEach(c => products.push(`${c.product._id}`)) } else { - products.push(`${item.content.product._id}`) + if (item.content.product) { + products.push(`${item.content.product._id}`) + } } return products diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index dd4d3eb86f..e45788cbb5 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -13,7 +13,7 @@ class CustomerService extends BaseService { PASSWORD_RESET: "customer.password_reset", } - constructor({ customerModel, orderService, eventBusService }) { + constructor({ customerModel, eventBusService }) { super() /** @private @const {CustomerModel} */ @@ -21,9 +21,6 @@ class CustomerService extends BaseService { /** @private @const {EventBus} */ this.eventBus_ = eventBusService - - /** @private @const {EventBus} */ - this.orderService_ = orderService } /** @@ -372,15 +369,6 @@ class CustomerService extends BaseService { const requiredFields = ["_id", "metadata"] const decorated = _.pick(customer, fields.concat(requiredFields)) - if (expandFields.includes("orders")) { - decorated.orders = await Promise.all( - customer.orders.map(async o => { - const order = await this.orderService_.retrieve(o) - return this.orderService_.decorate(order) - }) - ) - } - const final = await this.runDecorators_(decorated) return final } diff --git a/packages/medusa/src/services/fulfillment.js b/packages/medusa/src/services/fulfillment.js new file mode 100644 index 0000000000..684c60dbc3 --- /dev/null +++ b/packages/medusa/src/services/fulfillment.js @@ -0,0 +1,157 @@ +import _ from "lodash" +import { BaseService } from "medusa-interfaces" +import { MedusaError } from "medusa-core-utils" + +/** + * Handles Fulfillments + * @implements BaseService + */ +class FulfillmentService extends BaseService { + constructor({ + totalsService, + shippingProfileService, + fulfillmentProviderService, + }) { + super() + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + + this.shippingProfileService_ = shippingProfileService + + this.fulfillmentProviderService_ = fulfillmentProviderService + } + + 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 + } + + /** + * 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 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) + }) + ) + + return toReturn.filter(i => !!i) + } + + /** + * 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, + * 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(order, itemsToFulfill, metadata = {}) { + const lineItems = await this.getFulfillmentItems_( + order, + itemsToFulfill, + this.validateFulfillmentLineItem_ + ) + + const { shipping_methods } = order + + // partition order items to their dedicated shipping method + const fulfillments = await this.partitionItems_(shipping_methods, lineItems) + + return Promise.all( + fulfillments.map(async method => { + const provider = this.fulfillmentProviderService_.retrieveProvider( + method.provider_id + ) + + const data = await provider.createOrder(method.data, method.items, { + ...order, + }) + + return { + provider_id: method.provider_id, + items: method.items, + data, + metadata, + } + }) + ) + } + + async createShipment(order, fulfillment, trackingNumbers, metadata) { + return { + ...fulfillment, + tracking_numbers: trackingNumbers, + shipped_at: Date.now(), + metadata: { + ...fulfillment.metadata, + ...metadata, + }, + } + } +} + +export default FulfillmentService diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index 38f6e460f1..c779fa544d 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -13,6 +13,8 @@ class OrderService extends BaseService { ITEMS_RETURNED: "order.items_returned", RETURN_ACTION_REQUIRED: "order.return_action_required", REFUND_CREATED: "order.refund_created", + SWAP_CREATED: "order.swap_created", + SWAP_RECEIVED: "order.swap_received", PLACED: "order.placed", UPDATED: "order.updated", CANCELED: "order.canceled", @@ -22,22 +24,29 @@ class OrderService extends BaseService { constructor({ orderModel, counterService, + customerService, paymentProviderService, shippingOptionService, shippingProfileService, discountService, fulfillmentProviderService, + fulfillmentService, lineItemService, totalsService, regionService, + returnService, + swapService, documentService, eventBusService, }) { super() - /** @private @constantant {OrderModel} */ + /** @private @constant {OrderModel} */ this.orderModel_ = orderModel + /** @private @constant {CustomerService} */ + this.customerService_ = customerService + /** @private @constantant {PaymentProviderService} */ this.paymentProviderService_ = paymentProviderService @@ -56,6 +65,12 @@ class OrderService extends BaseService { /** @private @constant {RegionService} */ this.regionService_ = regionService + /** @private @constant {ReturnService} */ + this.returnService_ = returnService + + /** @private @constant {FulfillmentService} */ + this.fulfillmentService_ = fulfillmentService + /** @private @constant {DiscountService} */ this.discountService_ = discountService @@ -70,6 +85,9 @@ class OrderService extends BaseService { /** @private @constant {ShippingOptionService} */ this.shippingOptionService_ = shippingOptionService + + /** @private @constant {SwapService} */ + this.swapService_ = swapService } /** @@ -126,33 +144,6 @@ class OrderService extends BaseService { 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 - } - /** * @param {Object} selector - the query object for find * @return {Promise} the result of the find operation @@ -419,41 +410,49 @@ class OrderService extends BaseService { 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)) { - // For each item in the shipment, we set their status to shipped - f.items.map(item => { - const itemIdx = order.items.findIndex(el => el._id.equals(item._id)) - // Update item in order.items and in fullfillment.items to - // ensure consistency - if (item !== -1) { - item.shipped_quantity = item.quantity - order.items[itemIdx].shipped_quantity = - (order.items[itemIdx].shipped_quantity || 0) + item.quantity - } - }) - 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 fulfillment" + ) + } + + const updated = await this.fulfillmentService_.createShipment( + order, + shipment, + trackingNumbers, + metadata + ) let fulfillmentStatus = "shipped" - for (const item of order.items) { - if (item.quantity !== item.shipped_quantity) { - fulfillmentStatus = "partially_shipped" - break + + const newItems = order.items.map(item => { + const shipped = updated.items.find(fi => item._id.equals(fi._id)) + if (shipped) { + // Find the new fulfilled total + const shippedQuantity = (item.shipped_quantity || 0) + shipped.quantity + + // If the ordered quantity is not the same as the fulfilled quantity + // the order cannot be marked as fulfilled. We instead set it as + // partially_fulfilled. + if (item.quantity !== shippedQuantity) { + fulfillmentStatus = "partially_shipped" + } + + return { + ...item, + shipped: item.quantity === shippedQuantity, + shipped_quantity: shippedQuantity, + } } - } + + if (!item.shipped) { + fulfillmentStatus = "partially_shipped" + } + + return item + }) // Add the shipment to the order return this.orderModel_ @@ -462,7 +461,7 @@ class OrderService extends BaseService { { $set: { "fulfillments.$": updated, - items: order.items, + items: newItems, fulfillment_status: fulfillmentStatus, }, } @@ -739,85 +738,62 @@ class OrderService extends BaseService { async createFulfillment(orderId, itemsToFulfill, metadata = {}) { const order = await this.retrieve(orderId) - const lineItems = await this.getFulfillmentItems_( + const fulfillments = await this.fulfillmentService_.createFulfillment( order, itemsToFulfill, - this.validateFulfillmentLineItem_ + metadata ) - const { shipping_methods } = order - - const updateFields = {} - - // partition order items to their dedicated shipping method - const fulfillments = await this.partitionItems_(shipping_methods, lineItems) - let successfullyFulfilled = [] - const results = await Promise.all( - fulfillments.map(async method => { - const provider = this.fulfillmentProviderService_.retrieveProvider( - method.provider_id - ) + for (const f of fulfillments) { + successfullyFulfilled = [...successfullyFulfilled, ...f.items] + } - const data = await provider - .createOrder(method.data, method.items, { ...order }) - .then(res => { - successfullyFulfilled = [...successfullyFulfilled, ...method.items] - return res - }) - - method.items = method.items.map(el => { - return { - ...el, - fulfilled_quantity: el.quantity, - fulfilled: true, - } - }) - - return { - provider_id: method.provider_id, - items: method.items, - data, - metadata, - } - }) - ) + const updateFields = {} // Reflect the fulfillments in the items updateFields.items = order.items.map(i => { const ful = successfullyFulfilled.find(f => i._id.equals(f._id)) if (ful) { + // Find the new fulfilled total + const fulfilledQuantity = i.fulfilled_quantity + ful.quantity + + // If the ordered quantity is not the same as the fulfilled quantity + // the order cannot be marked as fulfilled. We instead set it as + // partially_fulfilled. + if (i.quantity !== fulfilledQuantity) { + updateFields.fulfillment_status = "partially_fulfilled" + } + + // Update the items return { ...i, - fulfilled: i.quantity === ful.quantity, - fulfilled_quantity: i.fulfilled_quantity + ful.quantity, + fulfilled: i.quantity === fulfilledQuantity, + fulfilled_quantity: fulfilledQuantity, } } + if (!i.fulfilled) { + updateFields.fulfillment_status = "partially_fulfilled" + } + return i }) updateFields.fulfillment_status = "fulfilled" - for (const el of updateFields.items) { - if (el.quantity !== el.fulfilled_quantity) { - updateFields.fulfillment_status = "partially_fulfilled" - break - } - } - return this.orderModel_ .updateOne( { _id: orderId, }, { - $addToSet: { fulfillments: { $each: results } }, + $addToSet: { fulfillments: { $each: fulfillments } }, $set: updateFields, } ) .then(result => { - for (const fulfillment of results) { + for (const fulfillment of fulfillments) { this.eventBus_.emit(OrderService.Events.FULFILLMENT_CREATED, { order_id: orderId, fulfillment, @@ -883,32 +859,6 @@ class OrderService extends BaseService { } } - /** - * 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" - ) { - 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" - ) - } - } - /** * Generates documents. * @param {Array} docs - documents to generate @@ -941,73 +891,13 @@ class OrderService extends BaseService { 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_( + const returnRequest = await this.returnService_.requestReturn( order, items, - this.validateReturnLineItem_ + shippingMethod, + refundAmount ) - 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( { @@ -1015,14 +905,14 @@ class OrderService extends BaseService { }, { $push: { - returns: newReturn, + returns: returnRequest, }, } ) .then(result => { this.eventBus_.emit(OrderService.Events.RETURN_REQUESTED, { order: result, - return: newReturn, + return: returnRequest, }) return result }) @@ -1040,7 +930,13 @@ class OrderService extends BaseService { * @param {string[]} lineItems - the line items to return * @return {Promise} the result of the update operation */ - async return(orderId, returnId, items, refundAmount, allowMismatch = false) { + async receiveReturn( + orderId, + returnId, + items, + refundAmount, + allowMismatch = false + ) { const order = await this.retrieve(orderId) const returnRequest = order.returns.find(r => r._id.equals(returnId)) if (!returnRequest) { @@ -1050,99 +946,24 @@ class OrderService extends BaseService { ) } - if (returnRequest.status === "received") { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Return with id ${returnId} has already been received` - ) - } - - const returnLines = await this.getFulfillmentItems_( + const updatedReturn = await this.returnService_.receiveReturn( order, + returnRequest, items, - this.validateReturnLineItem_ + refundAmount, + allowMismatch ) - 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 - } - }) + if (updatedReturn.status === "requires_action") { return this.orderModel_ .updateOne( { _id: orderId, + "returns._id": updatedReturn._id, }, { $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, + "returns.$": updatedReturn, }, } ) @@ -1157,7 +978,7 @@ class OrderService extends BaseService { let isFullReturn = true const newItems = order.items.map(i => { - const isReturn = returnLines.find(r => r._id.equals(i._id)) + const isReturn = updatedReturn.items.find(r => i._id.equals(r.item_id)) if (isReturn) { const returnedQuantity = i.returned_quantity + isReturn.quantity let returned = i.quantity === returnedQuantity @@ -1177,18 +998,9 @@ class OrderService extends BaseService { } }) - const newReturns = order.returns.map(r => { - if (r._id.equals(returnId)) { - return { - ...r, - status: "received", - items: newLines, - refund_amount: toRefund, - } - } else { - return r - } - }) + const newReturns = order.returns.map(r => + r._id.equals(returnId) ? updatedReturn : r + ) const update = { $set: { @@ -1198,15 +1010,15 @@ class OrderService extends BaseService { }, } - if (toRefund > 0) { + if (updatedReturn.refund_amount > 0) { const { provider_id, data } = order.payment_method const paymentProvider = this.paymentProviderService_.retrieveProvider( provider_id ) - await paymentProvider.refundPayment(data, toRefund) + await paymentProvider.refundPayment(data, updatedReturn.refund_amount) update.$push = { refunds: { - amount: toRefund, + amount: updatedReturn.refund_amount, }, } } @@ -1214,7 +1026,7 @@ class OrderService extends BaseService { 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: updatedReturn, }) return result }) @@ -1375,6 +1187,22 @@ class OrderService extends BaseService { o.created = order._id.getTimestamp() + if (expandFields.includes("swaps")) { + if (order.swaps) { + o.swaps = await Promise.all( + order.swaps.map(sId => { + return this.swapService_.retrieve(sId) + }) + ) + } else { + o.swaps = [] + } + } + + if (expandFields.includes("customer")) { + o.customer = await this.customerService_.retrieve(order.customer_id) + } + if (expandFields.includes("region")) { o.region = await this.regionService_.retrieve(order.region_id) } @@ -1422,6 +1250,87 @@ class OrderService extends BaseService { }) } + /** + * Registers a swap to the order. The swap must belong to the order for it to + * be registered. + * @param {string} id - the id of the order to register the swap to. + * @param {string} swapId - the id of the swap to add to the order. + * @returns {Promise} the resulting order + */ + async registerSwapCreated(id, swapId) { + const order = await this.retrieve(id) + const swap = await this.swapService_.retrieve(swapId) + + if (!order._id.equals(swap.order_id)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Swap must belong to the given order" + ) + } + + return this.orderModel_ + .updateOne({ _id: order._id }, { $addToSet: { swaps: swapId } }) + .then(result => { + this.eventBus_.emit(OrderService.Events.SWAP_CREATED, { + order: result, + swap_id: swapId, + }) + return result + }) + } + + /** + * Registers the swap return items as received so that they cannot be used + * as a part of other swaps/returns. + * @param {string} id - the id of the order with the swap. + * @param {string} swapId - the id of the swap that has been received. + * @returns {Promise} the resulting order + */ + async registerSwapReceived(id, swapId) { + const order = await this.retrieve(id) + const swap = await this.swapService_.retrieve(swapId) + + if (!order._id.equals(swap.order_id)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Swap must belong to the given order" + ) + } + + if (swap.return.status !== "received") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Swap is not received" + ) + } + + const newItems = order.items.map(i => { + const isReturn = swap.return_items.find(ri => i._id.equals(ri.item_id)) + + if (isReturn) { + const returnedQuantity = i.returned_quantity + isReturn.quantity + const returned = returnedQuantity === i.quantity + return { + ...i, + returned_quantity: returnedQuantity, + returned, + } + } + + return i + }) + + return this.orderModel_ + .updateOne({ _id: order._id }, { $set: { items: newItems } }) + .then(result => { + this.eventBus_.emit(OrderService.Events.SWAP_RECEIVED, { + order: result, + swap_id: swapId, + }) + return result + }) + } + /** * Dedicated method to delete metadata for an order. * @param {string} orderId - the order to delete metadata from. diff --git a/packages/medusa/src/services/return.js b/packages/medusa/src/services/return.js new file mode 100644 index 0000000000..34a4fc7db7 --- /dev/null +++ b/packages/medusa/src/services/return.js @@ -0,0 +1,274 @@ +import _ from "lodash" +import { BaseService } from "medusa-interfaces" +import { MedusaError } from "medusa-core-utils" + +/** + * Handles Returns + * @implements BaseService + */ +class ReturnService extends BaseService { + constructor({ + totalsService, + shippingOptionService, + fulfillmentProviderService, + }) { + super() + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + + this.shippingOptionService_ = shippingOptionService + + this.fulfillmentProviderService_ = fulfillmentProviderService + } + + /** + * 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 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) + }) + ) + + return toReturn.filter(i => !!i) + } + + /** + * 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" + ) { + 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" + ) + } + } + + /** + * 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, + "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" + ) + } + + return { + ...item, + quantity, + } + } + + /** + * 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(order, items, shippingMethod, refundAmount) { + // Throws if the order doesn't have the necessary status for return + this.validateReturnStatuses_(order) + + const returnLines = await this.getFulfillmentItems_( + order, + items, + this.validateReturnLineItem_ + ) + + 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) + } + + 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 * (1 + order.tax_rate) + ) + } + + return { + 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, + } + } + + /** + * 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 receiveReturn( + order, + returnRequest, + items, + refundAmount, + allowMismatch = false + ) { + 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 + return { + ...returnRequest, + status: "requires_action", + items: newLines, + } + } + + const toRefund = refundAmount || returnRequest.refund_amount + const total = await this.totalsService_.getTotal(order) + const refunded = await this.totalsService_.getRefundedTotal(order) + + if (toRefund > total - refunded) { + return { + ...returnRequest, + status: "requires_action", + items: newLines, + } + } + + return { + ...returnRequest, + status: "received", + items: newLines, + refund_amount: toRefund, + } + } +} + +export default ReturnService diff --git a/packages/medusa/src/services/shipping-profile.js b/packages/medusa/src/services/shipping-profile.js index 696c0f6a85..8a0b226e55 100644 --- a/packages/medusa/src/services/shipping-profile.js +++ b/packages/medusa/src/services/shipping-profile.js @@ -394,8 +394,11 @@ class ShippingProfileService extends BaseService { } }) } else { - if (!acc.includes(next.content.product._id)) { - acc.push(next.content.product._id) + // We may have line items that are not associated with a product + if (next.content.product) { + if (!acc.includes(next.content.product._id)) { + acc.push(next.content.product._id) + } } } diff --git a/packages/medusa/src/services/swap.js b/packages/medusa/src/services/swap.js new file mode 100644 index 0000000000..14d5743d86 --- /dev/null +++ b/packages/medusa/src/services/swap.js @@ -0,0 +1,673 @@ +import _ from "lodash" +import { BaseService } from "medusa-interfaces" +import { Validator, MedusaError } from "medusa-core-utils" + +/** + * Handles swaps + * @implements BaseService + */ +class SwapService extends BaseService { + static Events = { + SHIPMENT_CREATED: "swap.shipment_created", + PAYMENT_COMPLETED: "swap.payment_completed", + PAYMENT_CAPTURED: "swap.payment_captured", + PAYMENT_CAPTURE_FAILED: "swap.payment_capture_failed", + } + + constructor({ + swapModel, + eventBusService, + cartService, + totalsService, + returnService, + lineItemService, + paymentProviderService, + shippingOptionService, + fulfillmentService, + }) { + super() + + /** @private @const {SwapModel} */ + this.swapModel_ = swapModel + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + + /** @private @const {LineItemService} */ + this.lineItemService_ = lineItemService + + /** @private @const {ReturnService} */ + this.returnService_ = returnService + + /** @private @const {PaymentProviderService} */ + this.paymentProviderService_ = paymentProviderService + + /** @private @const {CartService} */ + this.cartService_ = cartService + + /** @private @const {FulfillmentService} */ + this.fulfillmentService_ = fulfillmentService + + /** @private @const {ShippingOptionService} */ + this.shippingOptionService_ = shippingOptionService + + /** @private @const {EventBusService} */ + this.eventBus_ = eventBusService + } + + /** + * Used to validate user ids. Throws an error if the cast fails + * @param {string} rawId - the raw user 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 swapId could not be casted to an ObjectId" + ) + } + + return value + } + + /** + * Retrieves a swap with the given id. + * @param {string} id - the id of the swap to retrieve + * @return {Promise} the swap + */ + async retrieve(id) { + const validatedId = this.validateId_(id) + const swap = await this.swapModel_.findOne({ _id: validatedId }) + + if (!swap) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Swap was not found") + } + + return swap + } + + /** + * Retrieves a swap based on its associated cart id + * @param {string} cartId - the cart id that the swap's cart has + * @return {Promise} the swap + */ + async retrieveByCartId(cartId) { + const swap = await this.swapModel_.findOne({ cart_id: cartId }) + + if (!swap) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Swap was not found") + } + + return swap + } + + /** + * @typedef OrderLike + * @property {Array} items - the items on the order + */ + + /** + * @typedef ReturnItem + * @property {string} item_id - the id of the item in the order to return from. + * @property {number} quantity - the amount of the item to return. + */ + + /** + * Goes through a list of return items to ensure that they exist on the + * original order. If the item exists it is verified that the quantity to + * return is not higher than the original quantity ordered. + * @param {OrderLike} order - the order to return from + * @param {Array} returnItems - the items to return + * @return {Array} the validated returnItems + */ + validateReturnItems_(order, returnItems) { + return returnItems.map(({ item_id, quantity }) => { + const item = order.items.find(i => i._id.equals(item_id)) + + // The item must exist in the order + if (!item) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Item does not exist on order" + ) + } + + // Item's cannot be returned multiple times + if (item.quantity < item.returned_quantity + quantity) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Cannot return more items than have been ordered" + ) + } + + return { item_id, quantity } + }) + } + + /** + * @typedef PreliminaryLineItem + * @property {string} variant_id - the id of the variant to create an item from + * @property {number} quantity - the amount of the variant to add to the line item + */ + + /** + * Creates a swap from an order, with given return items, additional items + * and an optional return shipping method. + * @param {Order} order - the order to base the swap off. + * @param {Array} returnItems - the items to return in the swap. + * @param {Array} additionalItems - the items to send to + * the customer. + * @param {ReturnShipping?} returnShipping - an optional shipping method for + * returning the returnItems. + * @returns {Promise} the newly created swap. + */ + async create(order, returnItems, additionalItems, returnShipping) { + if ( + order.fulfillment_status === "not_fulfilled" || + order.payment_status !== "captured" + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Order cannot be swapped" + ) + } + + const newItems = await Promise.all( + additionalItems.map(({ variant_id, quantity }) => { + return this.lineItemService_.generate( + variant_id, + order.region_id, + quantity + ) + }) + ) + + const validatedReturnItems = this.validateReturnItems_(order, returnItems) + + return this.swapModel_.create({ + order_id: order._id, + region_id: order.region_id, + currency_code: order.currency_code, + return_items: validatedReturnItems, + return_shipping: returnShipping, + additional_items: newItems, + }) + } + + async capturePayment(swapId) { + const swap = await this.retrieve(swapId) + + if (swap.payment_status !== "awaiting") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Payment already captured" + ) + } + + const updateFields = { payment_status: "captured" } + + const { provider_id, data } = swap.payment_method + const paymentProvider = await this.paymentProviderService_.retrieveProvider( + provider_id + ) + + try { + await paymentProvider.capturePayment(data) + } catch (error) { + return this.swapModel_ + .updateOne( + { + _id: swapId, + }, + { + $set: { payment_status: "requires_action" }, + } + ) + .then(result => { + this.eventBus_.emit(SwapService.Events.PAYMENT_CAPTURE_FAILED, result) + return result + }) + } + + return this.swapModel_ + .updateOne( + { + _id: swapId, + }, + { + $set: updateFields, + } + ) + .then(result => { + this.eventBus_.emit(SwapService.Events.PAYMENT_CAPTURED, result) + return result + }) + } + + /** + * Creates a cart from the given swap and order. The cart can be used to pay + * for differences associated with the swap. The swap represented by the + * swapId must belong to the order. Fails if there is already a cart on the + * swap. + * @param {Order} order - the order to create the cart from + * @param {string} swapId - the id of the swap to create the cart from + * @returns {Promise} the swap with its cart_id prop set to the id of + * the new cart. + */ + async createCart(order, swapId) { + const swap = await this.retrieve(swapId) + + if (!order._id.equals(swap.order_id)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The swap does not belong to the order" + ) + } + + if (swap.cart_id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "A cart has already been created for the swap" + ) + } + + // Add return lines to the cart to ensure that the total calculation is + // correct. + const returnLines = swap.return_items.map(r => { + const lineItem = order.items.find(i => i._id.equals(r.item_id)) + + return { + ...lineItem, + content: { + ...lineItem.content, + unit_price: -1 * lineItem.content.unit_price, + }, + quantity: r.quantity, + metadata: { + ...lineItem.metadata, + is_return_line: true, + }, + } + }) + + // If the swap has a return shipping method the price has to be added to the + // cart. + if (swap.return_shipping) { + returnLines.push({ + title: "Return shipping", + quantity: 1, + has_shipping: true, + content: { + unit_price: swap.return_shipping.price, + quantity: 1, + }, + metadata: { + is_return_line: true, + }, + }) + } + + const cart = await this.cartService_.create({ + email: order.email, + billing_address: order.billing_address, + shipping_address: order.shipping_address, + items: [...returnLines, ...swap.additional_items], + region_id: order.region_id, + customer_id: order.customer_id, + is_swap: true, + metadata: { + swap_id: swap._id, + parent_order_id: order._id, + }, + }) + + return this.swapModel_.updateOne( + { _id: swapId }, + { $set: { cart_id: cart._id } } + ) + } + + /** + * + */ + async registerCartCompletion(swapId, cartId) { + const swap = await this.retrieve(swapId) + const cart = await this.cartService_.retrieve(cartId) + + // If we already registered the cart completion we just return + if (swap.is_paid) { + return swap + } + + if (!cart._id.equals(swap.cart_id)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cart does not belong to swap" + ) + } + + let paymentSession = {} + let paymentData = {} + const { payment_method, payment_sessions } = cart + + if (!payment_method) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Cart does not contain a payment method" + ) + } + + if (!payment_sessions || !payment_sessions.length) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "cart must have payment sessions" + ) + } + + paymentSession = payment_sessions.find( + ps => ps.provider_id === payment_method.provider_id + ) + + // Throw if payment method does not exist + if (!paymentSession) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Cart does not have an authorized payment session" + ) + } + + const paymentProvider = this.paymentProviderService_.retrieveProvider( + paymentSession.provider_id + ) + const paymentStatus = await paymentProvider.getStatus(paymentSession.data) + + // If payment status is not authorized, we throw + if (paymentStatus !== "authorized" && paymentStatus !== "succeeded") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Payment method is not authorized" + ) + } + + paymentData = await paymentProvider.retrievePayment(paymentSession.data) + + let payment = {} + if (paymentSession.provider_id) { + payment = { + provider_id: paymentSession.provider_id, + data: paymentData, + } + } + + const total = await this.totalsService_.getTotal(cart) + + return this.swapModel_ + .updateOne( + { _id: swap._id }, + { + shipping_address: cart.shipping_address, + shipping_methods: cart.shipping_methods, + is_paid: true, + amount_paid: total, + payment_method: payment, + } + ) + .then(result => { + this.eventBus_.emit(SwapService.Events.PAYMENT_COMPLETED, { + swap: result, + }) + return result + }) + } + + /** + * Requests a return based off an order and a swap. + * @param {Order} order - the order to create the return from. + * @param {string} swapId - the id to create the return from + * @returns {Promise} the swap + */ + async requestReturn(order, swapId) { + const swap = await this.retrieve(swapId) + + const newReturn = await this.returnService_.requestReturn( + order, + swap.return_items, + swap.return_shipping + ) + + return this.swapModel_.updateOne( + { _id: swapId }, + { $set: { return: newReturn } } + ) + } + + /** + * Registers the return associated with a swap as received. If the return + * is received with mismatching return items the swap's status will be updated + * to requires_action. + * @param {Order} order - the order to receive the return based off + * @param {string} swapId - the id of the swap to receive. + * @param {Array} - the items that have been returned + * @returns {Promise} the resulting swap, with an updated return and + * status. + */ + async receiveReturn(order, swapId, returnItems) { + const swap = await this.retrieve(swapId) + + const returnRequest = swap.return + if (!returnRequest || !returnRequest._id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Swap has no return request" + ) + } + + if (!order._id.equals(swap.order_id)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The swap does not belong to the order" + ) + } + + const updatedReturn = await this.returnService_.receiveReturn( + order, + returnRequest, + returnItems, + returnRequest.refund_amount, + false + ) + + let status = "received" + if (updatedReturn.status === "requires_action") { + status = "requires_action" + } + + return this.swapModel_.updateOne( + { + _id: swapId, + }, + { + $set: { + status, + return: updatedReturn, + }, + } + ) + } + + /** + * Fulfills the addtional items associated with the swap. Will call the + * fulfillment providers associated with the shipping methods. + * @param {string} swapId - the id of the swap to fulfill, + * @param {object} metadata - optional metadata to attach to the fulfillment. + * @returns {Promise} the updated swap with new status and fulfillments. + */ + async createFulfillment(order, swapId, metadata = {}) { + const swap = await this.retrieve(swapId) + + const fulfillments = await this.fulfillmentService_.createFulfillment( + { + ...swap, + currency_code: order.currency_code, + tax_rate: order.tax_rate, + region_id: order.region_id, + display_id: order.display_id, + billing_address: order.billing_address, + items: swap.additional_items, + shipping_methods: swap.shipping_methods, + is_swap: true, + }, + swap.additional_items.map(i => ({ + item_id: i._id, + quantity: i.quantity, + })), + metadata + ) + + return this.swapModel_.updateOne( + { + _id: swapId, + }, + { + $set: { + fulfillment_status: "fulfilled", + fulfillments, + }, + } + ) + } + + /** + * Marks a fulfillment as shipped and attaches tracking numbers. + * @param {string} swapId - the id of the swap that has been shipped. + * @param {string} fulfillmentId - the id of the specific fulfillment that + * has been shipped + * @param {Array} trackingNumbers - the tracking numbers associated + * with the shipment + * @param {object} metadata - optional metadata to attach to the shipment. + * @returns {Promise} the updated swap with new fulfillments and status. + */ + async createShipment(swapId, fulfillmentId, trackingNumbers, metadata = {}) { + const swap = await this.retrieve(swapId) + + // Update the fulfillment to register + const updatedFulfillments = await Promise.all( + swap.fulfillments.map(f => { + if (f._id.equals(fulfillmentId)) { + return this.fulfillmentService_.createShipment( + { + items: swap.additional_items, + shipping_methods: swap.shipping_methods, + }, + f, + trackingNumbers, + metadata + ) + } + return f + }) + ) + + // Go through all the additional items in the swap + const updatedItems = swap.additional_items.map(i => { + let shipmentItem + for (const fulfillment of updatedFulfillments) { + const item = fulfillment.items.find(fi => i._id.equals(fi._id)) + if (!!item) { + shipmentItem = item + break + } + } + + if (shipmentItem) { + const shippedQuantity = i.shipped_quantity + shipmentItem.quantity + return { + ...i, + shipped: i.quantity === shippedQuantity, + shipped_quantity: shippedQuantity, + } + } + + return i + }) + + const fulfillment_status = updatedItems.every(i => i.shipped) + ? "shipped" + : "partially_shipped" + + return this.swapModel_ + .updateOne( + { _id: swapId }, + { + $set: { + fulfillment_status, + additional_items: updatedItems, + fulfillments: updatedFulfillments, + }, + } + ) + .then(result => { + this.eventBus_.emit(SwapService.Events.SHIPMENT_CREATED, { + swap_id: swapId, + shipment: result.fulfillments.find(f => f._id.equals(fulfillmentId)), + }) + return result + }) + } + + /** + * Dedicated method to set metadata for a swap. + * To ensure that plugins does not overwrite each + * others metadata fields, setMetadata is provided. + * @param {string} swapId - the swap to decorate. + * @param {string} key - key for metadata field + * @param {string} value - value for metadata field. + * @return {Promise} resolves to the updated result. + */ + async setMetadata(swapId, key, value) { + const validatedId = this.validateId_(swapId) + + 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.swapModel_ + .updateOne({ _id: validatedId }, { $set: { [keyPath]: value } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Dedicated method to delete metadata for a swap. + * @param {string} swapId - the order to delete metadata from. + * @param {string} key - key for metadata field + * @return {Promise} resolves to the updated result. + */ + async deleteMetadata(swapId, key) { + const validatedId = this.validateId_(swapId) + + 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.swapModel_ + .updateOne({ _id: validatedId }, { $unset: { [keyPath]: "" } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } +} + +export default SwapService