feat: return shipping and flow (#125)

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