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
This commit is contained in:
Sebastian Rindom authored and GitHub committed 2020-11-24 14:02:49 +01:00
1 parent c6996dd530
commit fd04233238
56 files changed
+3266 -564

No files matched your search

@@ -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")
@@ -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
)
}
}
}
}
@@ -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))
}
}
+2 -1
View File
@@ -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",
@@ -1,5 +0,0 @@
const mockAxios = jest.genMockFromModule("axios")
mockAxios.create = jest.fn(() => mockAxios)
export default mockAxios
@@ -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
)
}
}
@@ -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",
@@ -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
}
+76 -63
View File
@@ -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:
@@ -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)
@@ -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,
})
@@ -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(
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
}
}
@@ -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)
@@ -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
}
@@ -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
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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) {
@@ -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.
*/
@@ -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) {
@@ -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
}
}
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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
@@ -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()),
})
@@ -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
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
@@ -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",
+2
View File
@@ -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: {} },
}
}
+3 -3
View File
@@ -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: {} },
@@ -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 },
+1
View File
@@ -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: {} },
}
}
+35
View File
@@ -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
@@ -115,6 +115,7 @@ export const orders = {
profile_id: IdMap.getId("profile1"),
},
],
tax_rate: 0,
fulfillment_status: "fulfilled",
payment_status: "captured",
status: "completed",
@@ -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", () => {
+251 -43
View File
@@ -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,
@@ -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" } } }
)
})
})
})
})
+16 -5
View File
@@ -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
+1 -13
View File
@@ -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
}
+157
View File
@@ -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<Array<LineItem>>} 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
+209 -300
View File
@@ -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<Document>} 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<Order>} 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<Order>} 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.
+274
View File
@@ -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<Array<LineItem>>} 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<Order>} 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
@@ -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)
}
}
}
+673
View File
@@ -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<Swap>} 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<Swap>} 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<LineItem>} 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<ReturnItem>} returnItems - the items to return
* @return {Array<ReturnItems>} 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<ReturnItem>} returnItems - the items to return in the swap.
* @param {Array<PreliminaryLineItem>} additionalItems - the items to send to
* the customer.
* @param {ReturnShipping?} returnShipping - an optional shipping method for
* returning the returnItems.
* @returns {Promise<Swap>} 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<Swap>} 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<Swap>} 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<ReturnItem>} - the items that have been returned
* @returns {Promise<Swap>} 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<Swap>} 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<string>} trackingNumbers - the tracking numbers associated
* with the shipment
* @param {object} metadata - optional metadata to attach to the shipment.
* @returns {Promise<Swap>} 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<Swap>} 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