Files
medusa-store/packages/medusa/src/services/order.js
T
2020-07-05 11:56:02 +02:00

543 lines
15 KiB
JavaScript

import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
class OrderService extends BaseService {
static Events = {
PLACED: "order.placed",
UPDATED: "order.updated",
CANCELLED: "order.cancelled",
}
constructor({
orderModel,
paymentProviderService,
shippingProfileService,
fulfillmentProviderService,
lineItemService,
totalsService,
eventBusService,
}) {
super()
/** @private @const {OrderModel} */
this.orderModel_ = orderModel
/** @private @const {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @const {ShippingProvileService} */
this.shippingProfileService_ = shippingProfileService
/** @private @const {FulfillmentProviderService} */
this.fulfillmentProviderService_ = fulfillmentProviderService
/** @private @const {LineItemService} */
this.lineItemService_ = lineItemService
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
/**
* Used to validate order ids. Throws an error if the cast fails
* @param {string} rawId - the raw order id to validate.
* @return {string} the validated id
*/
validateId_(rawId) {
const schema = Validator.objectId()
const { value, error } = schema.validate(rawId.toString())
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"The order id could not be casted to an ObjectId"
)
}
return value
}
/**
* Used to validate order addresses. Can be used to both
* validate shipping and billing address.
* @param {Address} address - the address to validate
* @return {Address} the validated address
*/
validateAddress_(address) {
const { value, error } = Validator.address().validate(address)
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The address is not valid"
)
}
return value
}
/**
* Used to validate email.
* @param {string} email - the email to vaildate
* @return {string} the validate email
*/
validateEmail_(email) {
const schema = Validator.string().email()
const { value, error } = schema.validate(email)
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"The email is not valid"
)
}
return value
}
async partitionItems_(shipping_methods, items) {
let updatedMethods = []
// partition order items to their dedicated shipping method
await Promise.all(
shipping_methods.map(async method => {
const { profile_id } = method
const profile = await this.shippingProfileService_.retrieve(profile_id)
// for each method find the items in the order, that are associated
// with the profile on the current shipping method
if (shipping_methods.length === 1) {
method.items = items
} else {
method.items = items.filter(({ content }) => {
if (Array.isArray(content)) {
// we require bundles to have same shipping method, therefore:
return profile.products.includes(content[0].product._id)
} else {
return profile.products.includes(content.product._id)
}
})
}
updatedMethods.push(method)
})
)
return updatedMethods
}
/**
* Gets an order by id.
* @param {string} orderId - id of order to retrieve
* @return {Promise<Order>} the order document
*/
async retrieve(orderId) {
const validatedId = this.validateId_(orderId)
const order = await this.orderModel_
.findOne({ _id: validatedId })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
if (!order) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Order with ${orderId} was not found`
)
}
return order
}
/**
* @param {Object} selector - the query object for find
* @return {Promise} the result of the find operation
*/
list(selector) {
return this.orderModel_.find(selector)
}
/**
* Creates an order
* @param {object} order - the order to create
* @return {Promise} resolves to the creation result.
*/
async create(order) {
return this.orderModel_
.create(order)
.then(result => {
// Notify subscribers
this.eventBus_.emit(OrderService.Events.PLACED, result)
return result
})
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Updates an order. Metadata updates should
* use dedicated method, e.g. `setMetadata` etc. The function
* will throw errors if metadata updates are attempted.
* @param {string} orderId - the id of the order. Must be a string that
* can be casted to an ObjectId
* @param {object} update - an object with the update values.
* @return {Promise} resolves to the update result.
*/
async update(orderId, update) {
const order = await this.retrieve(orderId)
if (
(update.shipping_address ||
update.billing_address ||
update.payment_method ||
update.items) &&
(order.fulfillment_status !== "not_fulfilled" ||
order.payment_status !== "awaiting" ||
order.status !== "pending")
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't update shipping, billing, items and payment method when order is processed"
)
}
if (update.metadata) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Use setMetadata to update metadata fields"
)
}
if (update.status || update.fulfillment_status || update.payment_status) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't update order statuses. This will happen automatically. Use metadata in order for additional statuses"
)
}
const updateFields = { ...update }
if (update.shipping_address) {
updateFields.shipping_address = this.validateAddress_(
update.shipping_address
)
}
if (update.billing_address) {
updateFields.billing_address = this.validateAddress_(
update.billing_address
)
}
if (update.items) {
updateFields.items = update.items.map(item =>
this.lineItemService_.validate(item)
)
}
return this.orderModel_
.updateOne(
{ _id: order._id },
{ $set: updateFields },
{ runValidators: true }
)
.then(result => {
// Notify subscribers
this.eventBus_.emit(OrderService.Events.UPDATED, result)
return result
})
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Cancels an order.
* Throws if fulfillment process has been initiated.
* Throws if payment process has been initiated.
* @param {string} orderId - id of order to cancel.
* @return {Promise} result of the update operation.
*/
async cancel(orderId) {
const order = await this.retrieve(orderId)
if (order.fulfillment_status !== "not_fulfilled") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't cancel a fulfilled order"
)
}
if (order.payment_status !== "awaiting") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't cancel an order with payment processed"
)
}
// TODO: cancel payment method
return this.orderModel_
.updateOne(
{
_id: orderId,
},
{
$set: { status: "cancelled" },
}
)
.then(result => {
// Notify subscribers
this.eventBus_.emit(OrderService.Events.CANCELLED, result)
return result
})
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Captures payment for an order.
* @param {string} orderId - id of order to capture payment for.
* @return {Promise} result of the update operation.
*/
async capturePayment(orderId) {
const order = await this.retrieve(orderId)
if (order.payment_status !== "awaiting") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Payment already captured"
)
}
// prepare update object
const updateFields = { payment_status: "captured" }
const completed = order.fulfillment_status !== "not_fulfilled"
if (completed) {
updateFields.status = "completed"
}
const { provider_id, data } = order.payment_method
const paymentProvider = await this.paymentProviderService_.retrieveProvider(
provider_id
)
await paymentProvider.capturePayment(data)
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: updateFields,
}
)
}
/**
* Creates fulfillments for an order.
* In a situation where the order has more than one shipping method,
* we need to partition the order items, such that they can be sent
* to their respective fulfillment provider.
* @param {string} orderId - id of order to cancel.
* @return {Promise} result of the update operation.
*/
async createFulfillment(orderId) {
const order = await this.retrieve(orderId)
if (order.fulfillment_status !== "not_fulfilled") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Order is already fulfilled"
)
}
const { shipping_methods, items } = order
// prepare update object
const updateFields = { fulfillment_status: "fulfilled" }
const completed = order.payment_status !== "awaiting"
if (completed) {
updateFields.status = "completed"
}
// partition order items to their dedicated shipping method
order.shipping_methods = await this.partitionItems_(shipping_methods, items)
await Promise.all(
order.shipping_methods.map(method => {
const provider = this.fulfillmentProviderService_.retrieveProvider(
method.provider_id
)
provider.createOrder(method.data, method.items)
})
)
return this.orderModel_
.updateOne(
{
_id: orderId,
},
{
$set: updateFields,
}
)
.then(result => {
// Notify subscribers
this.eventBus_.emit(OrderService.Events.UPDATED, result)
return result
})
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Return either the entire or part of an order.
* @param {string} orderId - the order to return.
* @param {string[]} lineItems - the line items to return
* @return {Promise} the result of the update operation
*/
async return(orderId, lineItems) {
const order = await this.retrieve(orderId)
if (
order.fulfillment_status === "not_fulfilled" ||
order.fulfillment_status === "returned"
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't return an unfulfilled or already returned order"
)
}
if (order.payment_status !== "captured") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't return an order with payment unprocessed"
)
}
const { provider_id, data } = order.payment_method
const paymentProvider = this.paymentProviderService_.retrieveProvider(
provider_id
)
const amount = this.totalsService_.getRefundTotal(order, lineItems)
await paymentProvider.refundPayment(data, amount)
lineItems.map(item => {
const returnedItem = order.items.find(({ _id }) => _id === item._id)
if (returnedItem) {
returnedItem.returned_quantity = item.quantity
}
})
const fullReturn = order.items.every(
item => item.quantity === item.returned_quantity
)
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: {
items: order.items,
fulfillment_status: fullReturn ? "returned" : "partially_fulfilled",
},
}
)
}
/**
* Archives an order. It only alloved, if the order has been fulfilled
* and payment has been captured.
* @param {string} orderId - the order to archive
* @return {Promise} the result of the update operation
*/
async archive(orderId) {
const order = await this.retrieve(orderId)
if (order.status !== ("completed" || "refunded")) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't archive an unprocessed order"
)
}
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: { status: "archived" },
}
)
}
/**
* Decorates an order.
* @param {Order} order - the order to decorate.
* @param {string[]} fields - the fields to include.
* @param {string[]} expandFields - fields to expand.
* @return {Order} return the decorated order.
*/
async decorate(order, fields, expandFields = []) {
const requiredFields = ["_id", "metadata"]
const decorated = _.pick(order, fields.concat(requiredFields))
return decorated
}
/**
* Dedicated method to set metadata for an order.
* To ensure that plugins does not overwrite each
* others metadata fields, setMetadata is provided.
* @param {string} orderId - the order to decorate.
* @param {string} key - key for metadata field
* @param {string} value - value for metadata field.
* @return {Promise} resolves to the updated result.
*/
async setMetadata(orderId, key, value) {
const validatedId = this.validateId_(orderId)
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
const keyPath = `metadata.${key}`
return this.orderModel_
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Dedicated method to delete metadata for an order.
* @param {string} orderId - the order to delete metadata from.
* @param {string} key - key for metadata field
* @return {Promise} resolves to the updated result.
*/
async deleteMetadata(orderId, key) {
const validatedId = this.validateId_(orderId)
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
const keyPath = `metadata.${key}`
return this.orderModel_
.updateOne({ _id: validatedId }, { $unset: { [keyPath]: "" } })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
}
export default OrderService