Files
medusa-store/packages/medusa/src/services/swap.js

881 lines
26 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
/**
* Handles swaps
* @implements BaseService
*/
class SwapService extends BaseService {
static Events = {
CREATED: "swap.created",
RECEIVED: "swap.received",
SHIPMENT_CREATED: "swap.shipment_created",
PAYMENT_COMPLETED: "swap.payment_completed",
PAYMENT_CAPTURED: "swap.payment_captured",
PAYMENT_CAPTURE_FAILED: "swap.payment_capture_failed",
PROCESS_REFUND_FAILED: "swap.process_refund_failed",
REFUND_PROCESSED: "swap.refund_processed",
FULFILLMENT_CREATED: "swap.fulfillment_created",
}
constructor({
manager,
swapRepository,
eventBusService,
cartService,
totalsService,
returnService,
lineItemService,
paymentProviderService,
shippingOptionService,
fulfillmentService,
orderService,
}) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {SwapModel} */
this.swapRepository_ = swapRepository
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
/** @private @const {LineItemService} */
this.lineItemService_ = lineItemService
/** @private @const {ReturnService} */
this.returnService_ = returnService
/** @private @const {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @const {CartService} */
this.cartService_ = cartService
/** @private @const {FulfillmentService} */
this.fulfillmentService_ = fulfillmentService
/** @private @const {OrderService} */
this.orderService_ = orderService
/** @private @const {ShippingOptionService} */
this.shippingOptionService_ = shippingOptionService
/** @private @const {EventBusService} */
this.eventBus_ = eventBusService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new SwapService({
manager: transactionManager,
swapRepository: this.swapRepository_,
eventBusService: this.eventBus_,
cartService: this.cartService_,
totalsService: this.totalsService_,
returnService: this.returnService_,
lineItemService: this.lineItemService_,
paymentProviderService: this.paymentProviderService_,
shippingOptionService: this.shippingOptionService_,
orderService: this.orderService_,
fulfillmentService: this.fulfillmentService_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a swap with the given id.
* @param {string} id - the id of the swap to retrieve
* @return {Promise<Swap>} the swap
*/
async retrieve(id, config = {}) {
const swapRepo = this.manager_.getCustomRepository(this.swapRepository_)
const validatedId = this.validateId_(id)
const query = this.buildQuery_({ id: validatedId }, config)
const swap = await swapRepo.findOne(query)
if (!swap) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Swap was not found")
}
return swap
}
/**
* Retrieves a swap based on its associated cart id
* @param {string} cartId - the cart id that the swap's cart has
* @return {Promise<Swap>} the swap
*/
async retrieveByCartId(cartId, relations = []) {
const swapRepo = this.manager_.getCustomRepository(this.swapRepository_)
const swap = await swapRepo.findOne({
where: {
cart_id: cartId,
},
relations,
})
if (!swap) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Swap was not found")
}
return swap
}
/**
* @param {Object} selector - the query object for find
* @return {Promise} the result of the find operation
*/
list(
selector,
config = { skip: 0, take: 50, order: { created_at: "DESC" } }
) {
const swapRepo = this.manager_.getCustomRepository(this.swapRepository_)
const query = this.buildQuery_(selector, config)
return swapRepo.find(query)
}
/**
* @typedef OrderLike
* @property {Array<LineItem>} items - the items on the order
*/
/**
* @typedef ReturnItem
* @property {string} item_id - the id of the item in the order to return from.
* @property {number} quantity - the amount of the item to return.
*/
/**
* Goes through a list of return items to ensure that they exist on the
* original order. If the item exists it is verified that the quantity to
* return is not higher than the original quantity ordered.
* @param {OrderLike} order - the order to return from
* @param {Array<ReturnItem>} returnItems - the items to return
* @return {Array<ReturnItems>} the validated returnItems
*/
validateReturnItems_(order, returnItems) {
return returnItems.map(({ item_id, quantity }) => {
const item = order.items.find(i => i.id === item_id)
// The item must exist in the order
if (!item) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Item does not exist on order"
)
}
// Item's cannot be returned multiple times
if (item.quantity < item.returned_quantity + quantity) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Cannot return more items than have been ordered"
)
}
return { item_id, quantity }
})
}
/**
* @typedef PreliminaryLineItem
* @property {string} variant_id - the id of the variant to create an item from
* @property {number} quantity - the amount of the variant to add to the line item
*/
/**
* Creates a swap from an order, with given return items, additional items
* and an optional return shipping method.
* @param {Order} order - the order to base the swap off.
* @param {Array<ReturnItem>} returnItems - the items to return in the swap.
* @param {Array<PreliminaryLineItem>} additionalItems - the items to send to
* the customer.
* @param {ReturnShipping?} returnShipping - an optional shipping method for
* returning the returnItems.
* @param {boolean?} noNotification - an optional flag to disable sending
* notification when creating swap. If set, it overrules the attribute inherited
* from the order.
* @returns {Promise<Swap>} the newly created swap.
*/
async create(
order,
returnItems,
additionalItems,
returnShipping,
custom = {}
) {
return this.atomicPhase_(async manager => {
if (
order.fulfillment_status === "not_fulfilled" ||
order.payment_status !== "captured"
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Order cannot be swapped"
)
}
const newItems = await Promise.all(
additionalItems.map(({ variant_id, quantity }) => {
return this.lineItemService_.generate(
variant_id,
order.region_id,
quantity
)
})
)
const { noNotification, ...rest } = custom
const evaluatedNoNotification = noNotification !== undefined ? noNotification : order.no_notification
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const created = swapRepo.create({
...rest ,
fulfillment_status: "not_fulfilled",
payment_status: "not_paid",
order_id: order.id,
additional_items: newItems,
no_notification: evaluatedNoNotification
})
const result = await swapRepo.save(created)
await this.returnService_.withTransaction(manager).create({
swap_id: result.id,
order_id: order.id,
items: returnItems,
shipping_method: returnShipping,
no_notification: evaluatedNoNotification,
})
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.CREATED, {
id: result.id,
no_notification: evaluatedNoNotification,
})
return result
})
}
async processDifference(swapId) {
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(swapId, {
relations: ["payment", "order", "order.payments"],
})
if (!swap.confirmed_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot process a swap that hasn't been confirmed by the customer"
)
}
const swapRepo = manager.getCustomRepository(this.swapRepository_)
if (swap.difference_due < 0) {
if (swap.payment_status === "difference_refunded") {
return swap
}
try {
await this.paymentProviderService_
.withTransaction(manager)
.refundPayment(
swap.order.payments,
-1 * swap.difference_due,
"swap"
)
} catch (err) {
swap.payment_status = "requires_action"
const result = await swapRepo.save(swap)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.PROCESS_REFUND_FAILED, {
id: result.id,
no_notification: swap.no_notification,
})
return result
}
swap.payment_status = "difference_refunded"
const result = await swapRepo.save(swap)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.REFUND_PROCESSED, {
id: result.id,
no_notification: swap.no_notification,
})
return result
} else if (swap.difference_due === 0) {
if (swap.payment_status === "difference_refunded") {
return swap
}
swap.payment_status = "difference_refunded"
const result = await swapRepo.save(swap)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.REFUND_PROCESSED, {
id: result.id,
no_notification: swap.no_notification,
})
return result
}
try {
if (swap.payment_status === "captured") {
return swap
}
await this.paymentProviderService_
.withTransaction(manager)
.capturePayment(swap.payment)
} catch (err) {
swap.payment_status = "requires_action"
const result = await swapRepo.save(swap)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.PAYMENT_CAPTURE_FAILED, {
id: swap.id,
no_notification: swap.no_notification,
})
return result
}
swap.payment_status = "captured"
const result = await swapRepo.save(swap)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.PAYMENT_CAPTURED, {
id: result.id,
no_notification: swap.no_notification,
})
return result
})
}
async update(swapId, update) {
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(swapId)
if ( "metadata" in update ) {
swap.metadata = this.setMetadata_(swap, update.metadata)
}
if( "no_notification" in update ){
swap.no_notification = update.no_notification
}
if ( "shipping_address" in update ) {
await this.updateShippingAddress_(swap, update.shipping_address)
}
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const result = await swapRepo.save(swap)
return result
})
}
/**
* Creates a cart from the given swap and order. The cart can be used to pay
* for differences associated with the swap. The swap represented by the
* swapId must belong to the order. Fails if there is already a cart on the
* swap.
* @param {Order} order - the order to create the cart from
* @param {string} swapId - the id of the swap to create the cart from
* @returns {Promise<Swap>} the swap with its cart_id prop set to the id of
* the new cart.
*/
async createCart(swapId) {
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(swapId, {
relations: [
"order",
"order.items",
"order.swaps",
"order.swaps.additional_items",
"order.discounts",
"additional_items",
"return_order",
"return_order.items",
"return_order.shipping_method",
],
})
if (swap.cart_id) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"A cart has already been created for the swap"
)
}
const order = swap.order
// filter out free shipping discounts
const discounts =
order?.discounts?.filter(({ rule }) => rule.type !== "free_shipping") ||
undefined
const cart = await this.cartService_.withTransaction(manager).create({
discounts,
email: order.email,
billing_address_id: order.billing_address_id,
shipping_address_id: order.shipping_address_id,
region_id: order.region_id,
customer_id: order.customer_id,
type: "swap",
metadata: {
swap_id: swap.id,
parent_order_id: order.id,
},
})
for (const item of swap.additional_items) {
await this.lineItemService_.withTransaction(manager).update(item.id, {
cart_id: cart.id,
})
}
// If the swap has a return shipping method the price has to be added to the
// cart.
if (swap.return_order && swap.return_order.shipping_method) {
await this.lineItemService_.withTransaction(manager).create({
cart_id: cart.id,
title: "Return shipping",
quantity: 1,
has_shipping: true,
allow_discounts: false,
unit_price: swap.return_order.shipping_method.price,
metadata: {
is_return_line: true,
},
})
}
for (const r of swap.return_order.items) {
let allItems = [...order.items]
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
allItems = [...allItems, ...s.additional_items]
}
}
const lineItem = allItems.find(i => i.id === r.item_id)
const toCreate = {
cart_id: cart.id,
thumbnail: lineItem.thumbnail,
title: lineItem.title,
variant_id: lineItem.variant_id,
unit_price: -1 * lineItem.unit_price,
quantity: r.quantity,
metadata: {
...lineItem.metadata,
is_return_line: true,
},
}
await this.lineItemService_.withTransaction(manager).create(toCreate)
}
swap.cart_id = cart.id
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const result = await swapRepo.save(swap)
return result
})
}
/**
*
*/
async registerCartCompletion(swapId) {
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(swapId, {
relations: [
"cart",
"cart.region",
"cart.shipping_methods",
"cart.shipping_address",
"cart.items",
"cart.discounts",
"cart.discounts.rule",
"cart.payment",
"cart.gift_cards",
],
})
// If we already registered the cart completion we just return
if (swap.confirmed_at) {
return swap
}
const cart = swap.cart
const total = await this.totalsService_.getTotal(cart)
if (total > 0) {
const { payment } = cart
if (!payment) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Cart does not contain a payment"
)
}
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"
)
}
await this.paymentProviderService_
.withTransaction(manager)
.updatePayment(payment.id, {
swap_id: swapId,
order_id: swap.order_id,
})
}
const now = new Date()
swap.difference_due = total
swap.shipping_address_id = cart.shipping_address_id
swap.shipping_methods = cart.shipping_methods
swap.confirmed_at = now.toISOString()
swap.payment_status = total === 0 ? "difference_refunded" : "awaiting"
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const result = await swapRepo.save(swap)
for (const method of cart.shipping_methods) {
await this.shippingOptionService_
.withTransaction(manager)
.updateShippingMethod(method.id, {
swap_id: result.id,
})
}
this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.PAYMENT_COMPLETED, {
id: swap.id,
no_notification: swap.no_notification
})
return result
})
}
/**
* Registers the return associated with a swap as received. If the return
* is received with mismatching return items the swap's status will be updated
* to requires_action.
* @param {Order} order - the order to receive the return based off
* @param {string} swapId - the id of the swap to receive.
* @param {Array<ReturnItem>} - the items that have been returned
* @returns {Promise<Swap>} the resulting swap, with an updated return and
* status.
*/
async receiveReturn(swapId, returnItems) {
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(swapId, { relations: ["return_order"] })
const returnId = swap.return_order && swap.return_order.id
if (!returnId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Swap has no return request"
)
}
const updatedRet = await this.returnService_
.withTransaction(manager)
.receiveReturn(returnId, returnItems, undefined, false)
if (updatedRet.status === "requires_action") {
const swapRepo = manager.getCustomRepository(this.swapRepository_)
swap.fulfillment_status = "requires_action"
const result = await swapRepo.save(swap)
return result
}
return this.retrieve(swapId)
})
}
/**
* Fulfills the addtional items associated with the swap. Will call the
* fulfillment providers associated with the shipping methods.
* @param {string} swapId - the id of the swap to fulfill,
* @param {object} metadata - optional metadata to attach to the fulfillment.
* @returns {Promise<Swap>} the updated swap with new status and fulfillments.
*/
async createFulfillment(swapId, config = {
metadata: {},
noNotification: undefined
}) {
const { metadata, noNotification } = config
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(swapId, {
relations: [
"payment",
"shipping_address",
"additional_items",
"shipping_methods",
"order",
"order.billing_address",
"order.discounts",
"order.payments",
],
})
const order = swap.order
if (swap.fulfillment_status !== "not_fulfilled") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The swap was already fulfilled"
)
}
if (!swap.shipping_methods?.length) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot fulfill an swap that doesn't have shipping methods"
)
}
const evaluatedNoNotification = noNotification !== undefined ? noNotification : swap.no_notification
swap.fulfillments = await this.fulfillmentService_
.withTransaction(manager)
.createFulfillment(
{
...swap,
payments: swap.payment ? [swap.payment] : order.payments,
email: order.email,
discounts: order.discounts,
currency_code: order.currency_code,
tax_rate: order.tax_rate,
region_id: order.region_id,
display_id: order.display_id,
billing_address: order.billing_address,
items: swap.additional_items,
shipping_methods: swap.shipping_methods,
is_swap: true,
no_notification: evaluatedNoNotification,
},
swap.additional_items.map(i => ({
item_id: i.id,
quantity: i.quantity,
})),
{ swap_id: swapId, metadata }
)
let successfullyFulfilled = []
for (const f of swap.fulfillments) {
successfullyFulfilled = successfullyFulfilled.concat(f.items)
}
swap.fulfillment_status = "fulfilled"
// Update all line items to reflect fulfillment
for (const item of swap.additional_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) {
swap.fulfillment_status = "requires_action"
}
} else {
if (item.quantity !== item.fulfilled_quantity) {
swap.fulfillment_status = "requires_action"
}
}
}
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const result = await swapRepo.save(swap)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.FULFILLMENT_CREATED, {
id: swapId,
fulfillment_id: shipment.id,
no_notification: evaluatedNoNotification
})
return result
})
}
/**
* Marks a fulfillment as shipped and attaches tracking numbers.
* @param {string} swapId - the id of the swap that has been shipped.
* @param {string} fulfillmentId - the id of the specific fulfillment that
* has been shipped
* @param {TrackingLink[]} trackingLinks - the tracking numbers associated
* with the shipment
* @param {object} metadata - optional metadata to attach to the shipment.
* @returns {Promise<Swap>} the updated swap with new fulfillments and status.
*/
async createShipment(swapId, fulfillmentId, trackingLinks, config = {
metadata: {},
noNotification: undefined,
} ) {
const { metadata, noNotification } = config
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(swapId, {
relations: ["additional_items"],
})
const evaluatedNoNotification = noNotification !== undefined ? noNotification : swap.no_notification
// Update the fulfillment to register
const shipment = await this.fulfillmentService_
.withTransaction(manager)
.createShipment(fulfillmentId, trackingLinks, {metadata, noNotification: evaluatedNoNotification})
swap.fulfillment_status = "shipped"
// Go through all the additional items in the swap
for (const i of swap.additional_items) {
const shipped = shipment.items.find(si => si.item_id === i.id)
if (shipped) {
const shippedQty = (i.shipped_quantity || 0) + shipped.quantity
await this.lineItemService_.withTransaction(manager).update(i.id, {
shipped_quantity: shippedQty,
})
if (shippedQty !== i.quantity) {
swap.fulfillment_status = "partially_shipped"
}
} else {
if (i.shipped_quantity !== i.quantity) {
swap.fulfillment_status = "partially_shipped"
}
}
}
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const result = await swapRepo.save(swap)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.SHIPMENT_CREATED, {
id: swapId,
fulfillment_id: shipment.id,
no_notification: swap.no_notification
})
return result
})
}
/**
* Dedicated method to delete metadata for a swap.
* @param {string} swapId - the order to delete metadata from.
* @param {string} key - key for metadata field
* @return {Promise} resolves to the updated result.
*/
async deleteMetadata(swapId, key) {
const validatedId = this.validateId_(swapId)
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
const keyPath = `metadata.${key}`
return this.swapModel_
.updateOne({ _id: validatedId }, { $unset: { [keyPath]: "" } })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Registers the swap return items as received so that they cannot be used
* as a part of other swaps/returns.
* @param {string} id - the id of the order with the swap.
* @param {string} swapId - the id of the swap that has been received.
* @returns {Promise<Order>} the resulting order
*/
async registerReceived(id) {
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(id, {
relations: ["return_order", "return_order.items"],
})
if (swap.return_order.status !== "received") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Swap is not received"
)
}
const result = await this.retrieve(id)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.RECEIVED, {
id: id,
order_id: result.order_id,
no_notification: swap.no_notification
})
return result
})
}
}
export default SwapService