1360 lines
40 KiB
JavaScript
1360 lines
40 KiB
JavaScript
import _ from "lodash"
|
|
import { Validator, MedusaError } from "medusa-core-utils"
|
|
import { BaseService } from "medusa-interfaces"
|
|
import { Brackets } from "typeorm"
|
|
|
|
class OrderService extends BaseService {
|
|
static Events = {
|
|
GIFT_CARD_CREATED: "order.gift_card_created",
|
|
PAYMENT_CAPTURED: "order.payment_captured",
|
|
PAYMENT_CAPTURE_FAILED: "order.payment_capture_failed",
|
|
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",
|
|
REFUND_FAILED: "order.refund_failed",
|
|
SWAP_CREATED: "order.swap_created",
|
|
PLACED: "order.placed",
|
|
UPDATED: "order.updated",
|
|
CANCELED: "order.canceled",
|
|
COMPLETED: "order.completed",
|
|
}
|
|
|
|
constructor({
|
|
manager,
|
|
orderRepository,
|
|
customerService,
|
|
paymentProviderService,
|
|
shippingOptionService,
|
|
shippingProfileService,
|
|
discountService,
|
|
fulfillmentProviderService,
|
|
fulfillmentService,
|
|
lineItemService,
|
|
totalsService,
|
|
regionService,
|
|
cartService,
|
|
addressRepository,
|
|
giftCardService,
|
|
draftOrderService,
|
|
eventBusService,
|
|
}) {
|
|
super()
|
|
|
|
/** @private @constant {EntityManager} */
|
|
this.manager_ = manager
|
|
|
|
/** @private @constant {OrderRepository} */
|
|
this.orderRepository_ = orderRepository
|
|
|
|
/** @private @constant {CustomerService} */
|
|
this.customerService_ = customerService
|
|
|
|
/** @private @constant {PaymentProviderService} */
|
|
this.paymentProviderService_ = paymentProviderService
|
|
|
|
/** @private @constant {ShippingProvileService} */
|
|
this.shippingProfileService_ = shippingProfileService
|
|
|
|
/** @private @constant {FulfillmentProviderService} */
|
|
this.fulfillmentProviderService_ = fulfillmentProviderService
|
|
|
|
/** @private @constant {LineItemService} */
|
|
this.lineItemService_ = lineItemService
|
|
|
|
/** @private @constant {TotalsService} */
|
|
this.totalsService_ = totalsService
|
|
|
|
/** @private @constant {RegionService} */
|
|
this.regionService_ = regionService
|
|
|
|
/** @private @constant {FulfillmentService} */
|
|
this.fulfillmentService_ = fulfillmentService
|
|
|
|
/** @private @constant {DiscountService} */
|
|
this.discountService_ = discountService
|
|
|
|
/** @private @constant {DiscountService} */
|
|
this.giftCardService_ = giftCardService
|
|
|
|
/** @private @constant {EventBus} */
|
|
this.eventBus_ = eventBusService
|
|
|
|
/** @private @constant {ShippingOptionService} */
|
|
this.shippingOptionService_ = shippingOptionService
|
|
|
|
/** @private @constant {CartService} */
|
|
this.cartService_ = cartService
|
|
|
|
/** @private @constant {AddressRepository} */
|
|
this.addressRepository_ = addressRepository
|
|
|
|
/** @private @constant {DraftOrderService} */
|
|
this.draftOrderService_ = draftOrderService
|
|
}
|
|
|
|
withTransaction(manager) {
|
|
if (!manager) {
|
|
return this
|
|
}
|
|
|
|
const cloned = new OrderService({
|
|
manager,
|
|
orderRepository: this.orderRepository_,
|
|
eventBusService: this.eventBus_,
|
|
paymentProviderService: this.paymentProviderService_,
|
|
regionService: this.regionService_,
|
|
lineItemService: this.lineItemService_,
|
|
shippingOptionService: this.shippingOptionService_,
|
|
shippingProfileService: this.shippingProfileService_,
|
|
fulfillmentProviderService: this.fulfillmentProviderService_,
|
|
fulfillmentService: this.fulfillmentService_,
|
|
customerService: this.customerService_,
|
|
discountService: this.discountService_,
|
|
totalsService: this.totalsService_,
|
|
cartService: this.cartService_,
|
|
giftCardService: this.giftCardService_,
|
|
addressRepository: this.addressRepository_,
|
|
draftOrderService: this.draftOrderService_,
|
|
})
|
|
|
|
cloned.transactionManager_ = manager
|
|
cloned.manager_ = manager
|
|
|
|
return cloned
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return rawId
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* @param {Object} selector - the query object for find
|
|
* @return {Promise} the result of the find operation
|
|
*/
|
|
async list(
|
|
selector,
|
|
config = { skip: 0, take: 50, order: { created_at: "DESC" } }
|
|
) {
|
|
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
|
|
const query = this.buildQuery_(selector, config)
|
|
|
|
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
|
|
config
|
|
)
|
|
|
|
if (select && select.length) {
|
|
query.select = select
|
|
}
|
|
|
|
if (relations && relations.length) {
|
|
query.relations = relations
|
|
}
|
|
|
|
const raw = await orderRepo.find(query)
|
|
|
|
return raw.map(r => this.decorateTotals_(r, totalsToSelect))
|
|
}
|
|
|
|
async listAndCount(
|
|
selector,
|
|
config = { skip: 0, take: 50, order: { created_at: "DESC" } }
|
|
) {
|
|
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
|
|
|
|
let q
|
|
if ("q" in selector) {
|
|
q = selector.q
|
|
delete selector.q
|
|
}
|
|
|
|
const query = this.buildQuery_(selector, config)
|
|
|
|
if (q) {
|
|
const where = query.where
|
|
|
|
delete where.display_id
|
|
delete where.email
|
|
|
|
query.join = {
|
|
alias: "order",
|
|
innerJoin: {
|
|
shipping_address: "order.shipping_address",
|
|
},
|
|
}
|
|
|
|
query.where = qb => {
|
|
qb.where(where)
|
|
|
|
qb.andWhere(
|
|
new Brackets(qb => {
|
|
qb.where(`shipping_address.first_name ILIKE :q`, { q: `%${q}%` })
|
|
.orWhere(`order.email ILIKE :q`, { q: `%${q}%` })
|
|
.orWhere(`display_id::varchar(255) ILIKE :dId`, { dId: `${q}` })
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
|
|
config
|
|
)
|
|
|
|
if (select && select.length) {
|
|
query.select = select
|
|
}
|
|
|
|
let rels = relations
|
|
delete query.relations
|
|
|
|
const raw = await orderRepo.findWithRelations(rels, query)
|
|
const count = await orderRepo.count(query)
|
|
const orders = raw.map(r => this.decorateTotals_(r, totalsToSelect))
|
|
|
|
return [orders, count]
|
|
}
|
|
|
|
transformQueryForTotals_(config) {
|
|
let { select, relations } = config
|
|
|
|
if (!select) {
|
|
return {
|
|
select,
|
|
relations,
|
|
totalsToSelect: [],
|
|
}
|
|
}
|
|
|
|
const totalFields = [
|
|
"subtotal",
|
|
"tax_total",
|
|
"shipping_total",
|
|
"discount_total",
|
|
"gift_card_total",
|
|
"total",
|
|
"paid_total",
|
|
"refunded_total",
|
|
"refundable_amount",
|
|
"items.refundable",
|
|
"swaps.additional_items.refundable",
|
|
]
|
|
|
|
const totalsToSelect = select.filter(v => totalFields.includes(v))
|
|
if (totalsToSelect.length > 0) {
|
|
const relationSet = new Set(relations)
|
|
relationSet.add("items")
|
|
relationSet.add("swaps")
|
|
relationSet.add("discounts")
|
|
relationSet.add("gift_cards")
|
|
relationSet.add("gift_card_transactions")
|
|
relationSet.add("refunds")
|
|
relationSet.add("shipping_methods")
|
|
relationSet.add("region")
|
|
relations = [...relationSet]
|
|
|
|
select = select.filter(v => !totalFields.includes(v))
|
|
}
|
|
|
|
return {
|
|
relations,
|
|
select,
|
|
totalsToSelect,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets an order by id.
|
|
* @param {string} orderId - id of order to retrieve
|
|
* @return {Promise<Order>} the order document
|
|
*/
|
|
async retrieve(orderId, config = {}) {
|
|
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
|
|
const validatedId = this.validateId_(orderId)
|
|
|
|
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
|
|
config
|
|
)
|
|
|
|
const query = {
|
|
where: { id: validatedId },
|
|
}
|
|
|
|
if (relations && relations.length > 0) {
|
|
query.relations = relations
|
|
}
|
|
|
|
if (select && select.length > 0) {
|
|
query.select = select
|
|
}
|
|
|
|
const rels = query.relations
|
|
delete query.relations
|
|
const raw = await orderRepo.findOneWithRelations(rels, query)
|
|
if (!raw) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`Order with ${orderId} was not found`
|
|
)
|
|
}
|
|
|
|
const order = this.decorateTotals_(raw, totalsToSelect)
|
|
return order
|
|
}
|
|
|
|
/**
|
|
* Gets an order by cart id.
|
|
* @param {string} cartId - cart id to find order
|
|
* @return {Promise<Order>} the order document
|
|
*/
|
|
async retrieveByCartId(cartId, config = {}) {
|
|
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
|
|
|
|
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
|
|
config
|
|
)
|
|
|
|
const query = {
|
|
where: { cart_id: cartId },
|
|
}
|
|
|
|
if (relations && relations.length > 0) {
|
|
query.relations = relations
|
|
}
|
|
|
|
if (select && select.length > 0) {
|
|
query.select = select
|
|
}
|
|
|
|
const raw = await orderRepo.findOne(query)
|
|
|
|
if (!raw) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`Order with cart id: ${cartId} was not found`
|
|
)
|
|
}
|
|
|
|
const order = this.decorateTotals_(raw, totalsToSelect)
|
|
return order
|
|
}
|
|
|
|
/**
|
|
* Checks the existence of an order by cart id.
|
|
* @param {string} cartId - cart id to find order
|
|
* @return {Promise<Order>} the order document
|
|
*/
|
|
async existsByCartId(cartId) {
|
|
const order = await this.retrieveByCartId(cartId).catch(_ => undefined)
|
|
if (!order) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* @param {string} orderId - id of the order to complete
|
|
* @return {Promise} the result of the find operation
|
|
*/
|
|
async completeOrder(orderId) {
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId)
|
|
|
|
// Run all other registered events
|
|
const completeOrderJob = await this.eventBus_.emit(
|
|
OrderService.Events.COMPLETED,
|
|
{
|
|
id: orderId,
|
|
no_notification: order.no_notification,
|
|
}
|
|
)
|
|
|
|
await completeOrderJob.finished().catch(error => {
|
|
throw error
|
|
})
|
|
|
|
order.status = "completed"
|
|
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
return orderRepo.save(order)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates an order from a cart
|
|
* @param {string} cartId - id of the cart to create an order from
|
|
* @return {Promise} resolves to the creation result.
|
|
*/
|
|
async createFromCart(cartId) {
|
|
return this.atomicPhase_(async manager => {
|
|
const cart = await this.cartService_
|
|
.withTransaction(manager)
|
|
.retrieve(cartId, {
|
|
select: ["subtotal", "total"],
|
|
relations: [
|
|
"region",
|
|
"payment",
|
|
"items",
|
|
"discounts",
|
|
"gift_cards",
|
|
"shipping_methods",
|
|
],
|
|
})
|
|
|
|
if (cart.items.length === 0) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
"Cannot create order from empty cart"
|
|
)
|
|
}
|
|
|
|
const exists = await this.existsByCartId(cart.id)
|
|
if (exists) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_ARGUMENT,
|
|
"Order from cart already exists"
|
|
)
|
|
}
|
|
|
|
const { payment, region, total } = cart
|
|
// Would be the case if a discount code is applied that covers the item
|
|
// total
|
|
if (total !== 0) {
|
|
// Throw if payment method does not exist
|
|
if (!payment) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_ARGUMENT,
|
|
"Cart does not contain a payment method"
|
|
)
|
|
}
|
|
|
|
const paymentStatus = await this.paymentProviderService_
|
|
.withTransaction(manager)
|
|
.getStatus(payment)
|
|
|
|
// 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"
|
|
)
|
|
}
|
|
}
|
|
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
|
|
const toCreate = {
|
|
payment_status: "awaiting",
|
|
discounts: cart.discounts,
|
|
gift_cards: cart.gift_cards,
|
|
shipping_methods: cart.shipping_methods,
|
|
shipping_address_id: cart.shipping_address_id,
|
|
billing_address_id: cart.billing_address_id,
|
|
region_id: cart.region_id,
|
|
email: cart.email,
|
|
customer_id: cart.customer_id,
|
|
cart_id: cart.id,
|
|
tax_rate: region.tax_rate,
|
|
currency_code: region.currency_code,
|
|
metadata: cart.metadata || {},
|
|
}
|
|
|
|
if (cart.type === "draft_order") {
|
|
const draft = await this.draftOrderService_
|
|
.withTransaction(manager)
|
|
.retrieveByCartId(cart.id)
|
|
|
|
toCreate.draft_order_id = draft.id
|
|
toCreate.no_notification = draft.no_notification_order
|
|
}
|
|
|
|
const o = await orderRepo.create(toCreate)
|
|
|
|
const result = await orderRepo.save(o)
|
|
|
|
await this.paymentProviderService_
|
|
.withTransaction(manager)
|
|
.updatePayment(payment.id, {
|
|
order_id: result.id,
|
|
})
|
|
|
|
let gcBalance = cart.subtotal
|
|
for (const g of cart.gift_cards) {
|
|
const newBalance = Math.max(0, g.balance - gcBalance)
|
|
const usage = g.balance - newBalance
|
|
await this.giftCardService_.withTransaction(manager).update(g.id, {
|
|
balance: newBalance,
|
|
disabled: newBalance === 0,
|
|
})
|
|
|
|
await this.giftCardService_.withTransaction(manager).createTransaction({
|
|
gift_card_id: g.id,
|
|
order_id: result.id,
|
|
amount: usage,
|
|
})
|
|
|
|
gcBalance = gcBalance - usage
|
|
}
|
|
|
|
for (const method of cart.shipping_methods) {
|
|
await this.shippingOptionService_
|
|
.withTransaction(manager)
|
|
.updateShippingMethod(method.id, { order_id: result.id })
|
|
}
|
|
|
|
for (const item of cart.items) {
|
|
await this.lineItemService_
|
|
.withTransaction(manager)
|
|
.update(item.id, { order_id: result.id })
|
|
}
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.PLACED, {
|
|
id: result.id,
|
|
no_notification: result.no_notification,
|
|
})
|
|
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 {TrackingLink[]} trackingLinks - 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,
|
|
trackingLinks,
|
|
config = {
|
|
metadata: {},
|
|
no_notification: undefined,
|
|
}
|
|
) {
|
|
const { metadata, no_notification } = config
|
|
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId, { relations: ["items"] })
|
|
const shipment = await this.fulfillmentService_.retrieve(fulfillmentId)
|
|
|
|
if (!shipment || shipment.order_id !== orderId) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
"Could not find fulfillment"
|
|
)
|
|
}
|
|
|
|
const evaluatedNoNotification =
|
|
no_notification !== undefined
|
|
? no_notification
|
|
: shipment.no_notification
|
|
|
|
const shipmentRes = await this.fulfillmentService_
|
|
.withTransaction(manager)
|
|
.createShipment(fulfillmentId, trackingLinks, {
|
|
metadata,
|
|
no_notification: evaluatedNoNotification,
|
|
})
|
|
|
|
order.fulfillment_status = "shipped"
|
|
for (const item of order.items) {
|
|
const shipped = shipmentRes.items.find(si => si.item_id === item.id)
|
|
if (shipped) {
|
|
const shippedQty = (item.shipped_quantity || 0) + shipped.quantity
|
|
if (shippedQty !== item.quantity) {
|
|
order.fulfillment_status = "partially_shipped"
|
|
}
|
|
|
|
await this.lineItemService_.withTransaction(manager).update(item.id, {
|
|
shipped_quantity: shippedQty,
|
|
})
|
|
} else {
|
|
if (item.shipped_quantity !== item.quantity) {
|
|
order.fulfillment_status = "partially_shipped"
|
|
}
|
|
}
|
|
}
|
|
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
const result = await orderRepo.save(order)
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.SHIPMENT_CREATED, {
|
|
id: orderId,
|
|
fulfillment_id: shipmentRes.id,
|
|
no_notification: evaluatedNoNotification,
|
|
})
|
|
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates an order
|
|
* @param {object} order - the order to create
|
|
* @return {Promise} resolves to the creation result.
|
|
*/
|
|
async create(data) {
|
|
return this.atomicPhase_(async manager => {
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
const order = orderRepo.create(data)
|
|
const result = await orderRepo.save(order)
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.PLACED, {
|
|
id: result.id,
|
|
no_notification: order.no_notification,
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Updates the order's billing address.
|
|
* @param {string} orderId - the id of the order to update
|
|
* @param {object} address - the value to set the billing address to
|
|
* @return {Promise} the result of the update operation
|
|
*/
|
|
async updateBillingAddress_(order, address) {
|
|
const addrRepo = this.manager_.getCustomRepository(this.addressRepository_)
|
|
address.country_code = address.country_code.toLowerCase()
|
|
|
|
const region = await this.regionService_.retrieve(order.region_id, {
|
|
relations: ["countries"],
|
|
})
|
|
|
|
if (!region.countries.find(({ iso_2 }) => address.country_code === iso_2)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
"Shipping country must be in the order region"
|
|
)
|
|
}
|
|
|
|
address.country_code = address.country_code.toLowerCase()
|
|
|
|
if (order.billing_address_id) {
|
|
const addr = await addrRepo.findOne({
|
|
where: { id: order.billing_address_id },
|
|
})
|
|
|
|
await addrRepo.save({ ...addr, ...address })
|
|
} else {
|
|
const created = await addrRepo.create({ ...address })
|
|
await addrRepo.save(created)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the order's shipping address.
|
|
* @param {string} orderId - the id of the order to update
|
|
* @param {object} address - the value to set the shipping address to
|
|
* @return {Promise} the result of the update operation
|
|
*/
|
|
async updateShippingAddress_(order, address) {
|
|
const addrRepo = this.manager_.getCustomRepository(this.addressRepository_)
|
|
address.country_code = address.country_code.toLowerCase()
|
|
|
|
const region = await this.regionService_.retrieve(order.region_id, {
|
|
relations: ["countries"],
|
|
})
|
|
|
|
if (!region.countries.find(({ iso_2 }) => address.country_code === iso_2)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
"Shipping country must be in the order region"
|
|
)
|
|
}
|
|
|
|
if (order.shipping_address_id) {
|
|
const addr = await addrRepo.findOne({
|
|
where: { id: order.shipping_address_id },
|
|
})
|
|
|
|
await addrRepo.save({ ...addr, ...address })
|
|
} else {
|
|
const created = await addrRepo.create({ ...address })
|
|
await addrRepo.save(created)
|
|
}
|
|
}
|
|
|
|
async addShippingMethod(orderId, optionId, data, config = {}) {
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId, {
|
|
select: ["subtotal"],
|
|
relations: [
|
|
"shipping_methods",
|
|
"shipping_methods.shipping_option",
|
|
"items",
|
|
"items.variant",
|
|
"items.variant.product",
|
|
],
|
|
})
|
|
const { shipping_methods } = order
|
|
|
|
const newMethod = await this.shippingOptionService_
|
|
.withTransaction(manager)
|
|
.createShippingMethod(optionId, data, { order, ...config })
|
|
|
|
const methods = [newMethod]
|
|
if (shipping_methods.length) {
|
|
for (const sm of shipping_methods) {
|
|
if (
|
|
sm.shipping_option.profile_id ===
|
|
newMethod.shipping_option.profile_id
|
|
) {
|
|
await this.shippingOptionService_
|
|
.withTransaction(manager)
|
|
.deleteShippingMethod(sm)
|
|
} else {
|
|
methods.push(sm)
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = await this.retrieve(orderId)
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.UPDATED, { id: result.id })
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId)
|
|
|
|
if (
|
|
(update.payment || 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.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 {
|
|
metadata,
|
|
items,
|
|
billing_address,
|
|
shipping_address,
|
|
...rest
|
|
} = update
|
|
|
|
if ("metadata" in update) {
|
|
order.metadata = this.setMetadata_(order, update.metadata)
|
|
}
|
|
|
|
if ("shipping_address" in update) {
|
|
await this.updateShippingAddress_(order, update.shipping_address)
|
|
}
|
|
|
|
if ("billing_address" in update) {
|
|
await this.updateBillingAddress_(order, update.billing_address)
|
|
}
|
|
|
|
if ("no_notification" in update) {
|
|
order.no_notification = update.no_notification
|
|
}
|
|
|
|
if ("items" in update) {
|
|
for (const item of update.items) {
|
|
await this.lineItemService_.withTransaction(manager).create({
|
|
...item,
|
|
order_id: orderId,
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(rest)) {
|
|
order[key] = value
|
|
}
|
|
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
const result = await orderRepo.save(order)
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.UPDATED, {
|
|
id: orderId,
|
|
no_notification: order.no_notification,
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId, {
|
|
relations: ["fulfillments", "payments"],
|
|
})
|
|
|
|
if (order.payment_status !== "awaiting") {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_ALLOWED,
|
|
"Can't cancel an order with a processed payment"
|
|
)
|
|
}
|
|
|
|
await Promise.all(
|
|
order.fulfillments.map(fulfillment =>
|
|
this.fulfillmentService_
|
|
.withTransaction(manager)
|
|
.cancelFulfillment(fulfillment)
|
|
)
|
|
)
|
|
|
|
for (const p of order.payments) {
|
|
await this.paymentProviderService_
|
|
.withTransaction(manager)
|
|
.cancelPayment(p)
|
|
}
|
|
|
|
order.status = "canceled"
|
|
order.fulfillment_status = "canceled"
|
|
order.payment_status = "canceled"
|
|
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
const result = await orderRepo.save(order)
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.CANCELED, {
|
|
id: order.id,
|
|
no_notification: order.no_notification,
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return this.atomicPhase_(async manager => {
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
const order = await this.retrieve(orderId, { relations: ["payments"] })
|
|
|
|
const payments = []
|
|
for (const p of order.payments) {
|
|
if (p.captured_at === null) {
|
|
const result = await this.paymentProviderService_
|
|
.withTransaction(manager)
|
|
.capturePayment(p)
|
|
.catch(err => {
|
|
this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.PAYMENT_CAPTURE_FAILED, {
|
|
id: orderId,
|
|
payment_id: p.id,
|
|
error: err,
|
|
no_notification: order.no_notification,
|
|
})
|
|
})
|
|
|
|
if (result) {
|
|
payments.push(result)
|
|
} else {
|
|
payments.push(p)
|
|
}
|
|
} else {
|
|
payments.push(p)
|
|
}
|
|
}
|
|
|
|
order.payments = payments
|
|
order.payment_status = payments.every(p => p.captured_at !== null)
|
|
? "captured"
|
|
: "requires_action"
|
|
|
|
const result = await orderRepo.save(order)
|
|
|
|
if (order.payment_status === "captured") {
|
|
this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.PAYMENT_CAPTURED, {
|
|
id: result.id,
|
|
no_notification: order.no_notification,
|
|
})
|
|
}
|
|
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
orderId,
|
|
itemsToFulfill,
|
|
config = {
|
|
no_notification: undefined,
|
|
metadata: {},
|
|
}
|
|
) {
|
|
const { metadata, no_notification } = config
|
|
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId, {
|
|
select: [
|
|
"subtotal",
|
|
"shipping_total",
|
|
"discount_total",
|
|
"tax_total",
|
|
"gift_card_total",
|
|
"no_notification",
|
|
"id",
|
|
"total",
|
|
],
|
|
relations: [
|
|
"discounts",
|
|
"region",
|
|
"fulfillments",
|
|
"shipping_address",
|
|
"billing_address",
|
|
"shipping_methods",
|
|
"shipping_methods.shipping_option",
|
|
"items",
|
|
"items.variant",
|
|
"items.variant.product",
|
|
"payments",
|
|
],
|
|
})
|
|
|
|
if (!order.shipping_methods?.length) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_ALLOWED,
|
|
"Cannot fulfill an order that lacks shipping methods"
|
|
)
|
|
}
|
|
|
|
const fulfillments = await this.fulfillmentService_
|
|
.withTransaction(manager)
|
|
.createFulfillment(order, itemsToFulfill, {
|
|
metadata,
|
|
no_notification: no_notification,
|
|
order_id: orderId,
|
|
})
|
|
let successfullyFulfilled = []
|
|
for (const f of fulfillments) {
|
|
successfullyFulfilled = [...successfullyFulfilled, ...f.items]
|
|
}
|
|
|
|
order.fulfillment_status = "fulfilled"
|
|
|
|
// Update all line items to reflect fulfillment
|
|
for (const item of order.items) {
|
|
const fulfillmentItem = successfullyFulfilled.find(
|
|
f => item.id === f.item_id
|
|
)
|
|
|
|
if (fulfillmentItem) {
|
|
const fulfilledQuantity =
|
|
(item.fulfilled_quantity || 0) + fulfillmentItem.quantity
|
|
|
|
// Update the fulfilled quantity
|
|
await this.lineItemService_.withTransaction(manager).update(item.id, {
|
|
fulfilled_quantity: fulfilledQuantity,
|
|
})
|
|
|
|
if (item.quantity !== fulfilledQuantity) {
|
|
order.fulfillment_status = "partially_fulfilled"
|
|
}
|
|
} else {
|
|
if (item.quantity !== item.fulfilled_quantity) {
|
|
order.fulfillment_status = "partially_fulfilled"
|
|
}
|
|
}
|
|
}
|
|
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
|
|
order.fulfillments = [...order.fulfillments, ...fulfillments]
|
|
const result = await orderRepo.save(order)
|
|
|
|
const evaluatedNoNotification =
|
|
no_notification !== undefined ? no_notification : order.no_notification
|
|
|
|
for (const fulfillment of fulfillments) {
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.FULFILLMENT_CREATED, {
|
|
id: orderId,
|
|
fulfillment_id: fulfillment.id,
|
|
no_notification: evaluatedNoNotification,
|
|
})
|
|
}
|
|
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId)
|
|
|
|
if (order.status !== ("completed" || "refunded")) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_ALLOWED,
|
|
"Can't archive an unprocessed order"
|
|
)
|
|
}
|
|
|
|
order.status = "archived"
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
const result = await orderRepo.save(order)
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Refunds a given amount back to the customer.
|
|
*/
|
|
async createRefund(
|
|
orderId,
|
|
refundAmount,
|
|
reason,
|
|
note,
|
|
config = {
|
|
no_notification: undefined,
|
|
}
|
|
) {
|
|
const { no_notification } = config
|
|
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId, {
|
|
select: ["refundable_amount", "total", "refunded_total"],
|
|
relations: ["payments"],
|
|
})
|
|
|
|
if (refundAmount > order.refundable_amount) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_ALLOWED,
|
|
"Cannot refund more than the original order amount"
|
|
)
|
|
}
|
|
|
|
const refund = await this.paymentProviderService_
|
|
.withTransaction(manager)
|
|
.refundPayment(order.payments, refundAmount, reason, note)
|
|
|
|
const result = await this.retrieve(orderId)
|
|
|
|
const evaluatedNoNotification =
|
|
no_notification !== undefined ? no_notification : order.no_notification
|
|
|
|
this.eventBus_.emit(OrderService.Events.REFUND_CREATED, {
|
|
id: result.id,
|
|
refund_id: refund.id,
|
|
no_notification: evaluatedNoNotification,
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
decorateTotals_(order, totalsFields = []) {
|
|
if (totalsFields.includes("shipping_total")) {
|
|
order.shipping_total = this.totalsService_.getShippingTotal(order)
|
|
}
|
|
if (totalsFields.includes("gift_card_total")) {
|
|
order.gift_card_total = this.totalsService_.getGiftCardTotal(order)
|
|
}
|
|
if (totalsFields.includes("discount_total")) {
|
|
order.discount_total = this.totalsService_.getDiscountTotal(order)
|
|
}
|
|
if (totalsFields.includes("tax_total")) {
|
|
order.tax_total = this.totalsService_.getTaxTotal(order)
|
|
}
|
|
if (totalsFields.includes("subtotal")) {
|
|
order.subtotal = this.totalsService_.getSubtotal(order)
|
|
}
|
|
if (totalsFields.includes("total")) {
|
|
order.total = this.totalsService_.getTotal(order)
|
|
}
|
|
if (totalsFields.includes("refunded_total")) {
|
|
order.refunded_total = this.totalsService_.getRefundedTotal(order)
|
|
}
|
|
if (totalsFields.includes("paid_total")) {
|
|
order.paid_total = this.totalsService_.getPaidTotal(order)
|
|
}
|
|
if (totalsFields.includes("refundable_amount")) {
|
|
const paid_total = this.totalsService_.getPaidTotal(order)
|
|
const refunded_total = this.totalsService_.getRefundedTotal(order)
|
|
order.refundable_amount = paid_total - refunded_total
|
|
}
|
|
|
|
if (totalsFields.includes("items.refundable")) {
|
|
order.items = order.items.map(i => ({
|
|
...i,
|
|
refundable: this.totalsService_.getLineItemRefund(order, {
|
|
...i,
|
|
quantity: i.quantity - (i.returned_quantity || 0),
|
|
}),
|
|
}))
|
|
}
|
|
|
|
if (
|
|
totalsFields.includes("swaps.additional_items.refundable") &&
|
|
order.swaps &&
|
|
order.swaps.length
|
|
) {
|
|
for (const s of order.swaps) {
|
|
s.additional_items = s.additional_items.map(i => ({
|
|
...i,
|
|
refundable: this.totalsService_.getLineItemRefund(order, {
|
|
...i,
|
|
quantity: i.quantity - (i.returned_quantity || 0),
|
|
}),
|
|
}))
|
|
}
|
|
}
|
|
|
|
return order
|
|
}
|
|
|
|
/**
|
|
* Handles receiving a return. 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 {object} receivedReturn - the received return
|
|
* @return {Promise} the result of the update operation
|
|
*/
|
|
async registerReturnReceived(orderId, receivedReturn, customRefundAmount) {
|
|
return this.atomicPhase_(async manager => {
|
|
const order = await this.retrieve(orderId, {
|
|
select: ["total", "refunded_total", "refundable_amount"],
|
|
relations: ["items", "returns", "payments"],
|
|
})
|
|
|
|
if (!receivedReturn || receivedReturn.order_id !== orderId) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`Received return does not exist`
|
|
)
|
|
}
|
|
|
|
let refundAmount = customRefundAmount || receivedReturn.refund_amount
|
|
|
|
const orderRepo = manager.getCustomRepository(this.orderRepository_)
|
|
|
|
if (refundAmount > order.refundable_amount) {
|
|
order.fulfillment_status = "requires_action"
|
|
const result = await orderRepo.save(order)
|
|
this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.RETURN_ACTION_REQUIRED, {
|
|
id: result.id,
|
|
return_id: receivedReturn.id,
|
|
no_notification: receivedReturn.no_notification,
|
|
})
|
|
return result
|
|
}
|
|
|
|
let isFullReturn = true
|
|
for (const i of order.items) {
|
|
if (i.returned_quantity !== i.quantity) {
|
|
isFullReturn = false
|
|
}
|
|
}
|
|
|
|
if (receivedReturn.refund_amount > 0) {
|
|
const refund = await this.paymentProviderService_
|
|
.withTransaction(manager)
|
|
.refundPayment(order.payments, receivedReturn.refund_amount, "return")
|
|
|
|
order.refunds = [...(order.refunds || []), refund]
|
|
}
|
|
|
|
if (isFullReturn) {
|
|
order.fulfillment_status = "returned"
|
|
} else {
|
|
order.fulfillment_status = "partially_returned"
|
|
}
|
|
|
|
const result = await orderRepo.save(order)
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(OrderService.Events.ITEMS_RETURNED, {
|
|
id: order.id,
|
|
return_id: receivedReturn.id,
|
|
no_notification: receivedReturn.no_notification,
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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
|