Files
medusa-store/packages/medusa/src/services/cart.ts
T

2791 lines
86 KiB
TypeScript

import { isEmpty, isEqual } from "lodash"
import { isDefined, MedusaError } from "medusa-core-utils"
import { DeepPartial, EntityManager, In, IsNull, Not } from "typeorm"
import { IPriceSelectionStrategy, TransactionBaseService } from "../interfaces"
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
import {
Address,
Cart,
Customer,
CustomShippingOption,
Discount,
DiscountRule,
DiscountRuleType,
LineItem,
PaymentSession,
PaymentSessionStatus,
SalesChannel,
ShippingMethod,
} from "../models"
import { AddressRepository } from "../repositories/address"
import { CartRepository } from "../repositories/cart"
import { LineItemRepository } from "../repositories/line-item"
import { PaymentSessionRepository } from "../repositories/payment-session"
import { ShippingMethodRepository } from "../repositories/shipping-method"
import {
CartCreateProps,
CartUpdateProps,
FilterableCartProps,
isCart,
LineItemUpdate,
LineItemValidateData,
} from "../types/cart"
import {
AddressPayload,
FindConfig,
TotalField,
WithRequiredProperty,
} from "../types/common"
import { buildQuery, setMetadata } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import { validateEmail } from "../utils/is-email"
import { PaymentSessionInput } from "../types/payment"
import {
CustomerService,
CustomShippingOptionService,
DiscountService,
EventBusService,
GiftCardService,
LineItemAdjustmentService,
LineItemService,
NewTotalsService,
PaymentProviderService,
ProductService,
ProductVariantInventoryService,
ProductVariantService,
RegionService,
SalesChannelService,
ShippingOptionService,
StoreService,
TaxProviderService,
TotalsService,
} from "."
type InjectedDependencies = {
manager: EntityManager
cartRepository: typeof CartRepository
shippingMethodRepository: typeof ShippingMethodRepository
addressRepository: typeof AddressRepository
paymentSessionRepository: typeof PaymentSessionRepository
lineItemRepository: typeof LineItemRepository
eventBusService: EventBusService
salesChannelService: SalesChannelService
taxProviderService: TaxProviderService
paymentProviderService: PaymentProviderService
productService: ProductService
storeService: StoreService
featureFlagRouter: FlagRouter
productVariantService: ProductVariantService
regionService: RegionService
lineItemService: LineItemService
shippingOptionService: ShippingOptionService
customerService: CustomerService
discountService: DiscountService
giftCardService: GiftCardService
totalsService: TotalsService
newTotalsService: NewTotalsService
customShippingOptionService: CustomShippingOptionService
lineItemAdjustmentService: LineItemAdjustmentService
priceSelectionStrategy: IPriceSelectionStrategy
productVariantInventoryService: ProductVariantInventoryService
}
type TotalsConfig = {
force_taxes?: boolean
}
/* Provides layer to manipulate carts.
* @implements BaseService
*/
class CartService extends TransactionBaseService {
static readonly Events = {
CUSTOMER_UPDATED: "cart.customer_updated",
CREATED: "cart.created",
UPDATED: "cart.updated",
}
protected readonly shippingMethodRepository_: typeof ShippingMethodRepository
protected readonly cartRepository_: typeof CartRepository
protected readonly addressRepository_: typeof AddressRepository
protected readonly paymentSessionRepository_: typeof PaymentSessionRepository
protected readonly lineItemRepository_: typeof LineItemRepository
protected readonly eventBus_: EventBusService
protected readonly productVariantService_: ProductVariantService
protected readonly productService_: ProductService
protected readonly storeService_: StoreService
protected readonly salesChannelService_: SalesChannelService
protected readonly regionService_: RegionService
protected readonly lineItemService_: LineItemService
protected readonly paymentProviderService_: PaymentProviderService
protected readonly customerService_: CustomerService
protected readonly shippingOptionService_: ShippingOptionService
protected readonly discountService_: DiscountService
protected readonly giftCardService_: GiftCardService
protected readonly taxProviderService_: TaxProviderService
protected readonly totalsService_: TotalsService
protected readonly newTotalsService_: NewTotalsService
protected readonly customShippingOptionService_: CustomShippingOptionService
protected readonly priceSelectionStrategy_: IPriceSelectionStrategy
protected readonly lineItemAdjustmentService_: LineItemAdjustmentService
protected readonly featureFlagRouter_: FlagRouter
// eslint-disable-next-line max-len
protected readonly productVariantInventoryService_: ProductVariantInventoryService
constructor({
cartRepository,
shippingMethodRepository,
lineItemRepository,
eventBusService,
paymentProviderService,
productService,
productVariantService,
taxProviderService,
regionService,
lineItemService,
shippingOptionService,
customerService,
discountService,
giftCardService,
totalsService,
newTotalsService,
addressRepository,
paymentSessionRepository,
customShippingOptionService,
lineItemAdjustmentService,
priceSelectionStrategy,
salesChannelService,
featureFlagRouter,
storeService,
productVariantInventoryService,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.shippingMethodRepository_ = shippingMethodRepository
this.cartRepository_ = cartRepository
this.lineItemRepository_ = lineItemRepository
this.eventBus_ = eventBusService
this.productVariantService_ = productVariantService
this.productService_ = productService
this.regionService_ = regionService
this.lineItemService_ = lineItemService
this.paymentProviderService_ = paymentProviderService
this.customerService_ = customerService
this.shippingOptionService_ = shippingOptionService
this.discountService_ = discountService
this.giftCardService_ = giftCardService
this.totalsService_ = totalsService
this.newTotalsService_ = newTotalsService
this.addressRepository_ = addressRepository
this.paymentSessionRepository_ = paymentSessionRepository
this.customShippingOptionService_ = customShippingOptionService
this.taxProviderService_ = taxProviderService
this.lineItemAdjustmentService_ = lineItemAdjustmentService
this.priceSelectionStrategy_ = priceSelectionStrategy
this.salesChannelService_ = salesChannelService
this.featureFlagRouter_ = featureFlagRouter
this.storeService_ = storeService
this.productVariantInventoryService_ = productVariantInventoryService
}
/**
* @param selector - the query object for find
* @param config - config object
* @return the result of the find operation
*/
async list(
selector: FilterableCartProps,
config: FindConfig<Cart> = {}
): Promise<Cart[]> {
const cartRepo = this.activeManager_.withRepository(this.cartRepository_)
const query = buildQuery(selector, config)
return await cartRepo.find(query)
}
/**
* Gets a cart by id.
* @param cartId - the id of the cart to get.
* @param options - the options to get a cart
* @param totalsConfig
* @return the cart document.
*/
async retrieve(
cartId: string,
options: FindConfig<Cart> = {},
totalsConfig: TotalsConfig = {}
): Promise<Cart> {
if (!isDefined(cartId)) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`"cartId" must be defined`
)
}
const { totalsToSelect } = this.transformQueryForTotals_(options)
if (totalsToSelect.length) {
return await this.retrieveLegacy(cartId, options, totalsConfig)
}
const cartRepo = this.activeManager_.withRepository(this.cartRepository_)
const query = buildQuery({ id: cartId }, options)
if ((options.select || []).length === 0) {
query.select = undefined
}
const raw = await cartRepo.findOne(query)
if (!raw) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Cart with ${cartId} was not found`
)
}
return raw
}
/**
* @deprecated
* @param cartId
* @param options
* @param totalsConfig
* @protected
*/
protected async retrieveLegacy(
cartId: string,
options: FindConfig<Cart> = {},
totalsConfig: TotalsConfig = {}
): Promise<Cart> {
const cartRepo = this.activeManager_.withRepository(this.cartRepository_)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(options)
const query = buildQuery({ id: cartId }, {
...options,
select,
relations,
} as FindConfig<Cart>)
const raw = await cartRepo.findOne(query)
if (!raw) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Cart with ${cartId} was not found`
)
}
return await this.decorateTotals_(raw, totalsToSelect, totalsConfig)
}
async retrieveWithTotals(
cartId: string,
options: FindConfig<Cart> = {},
totalsConfig: TotalsConfig = {}
): Promise<WithRequiredProperty<Cart, "total">> {
const relations = this.getTotalsRelations(options)
const cart = await this.retrieve(cartId, {
...options,
relations,
})
return await this.decorateTotals(cart, totalsConfig)
}
/**
* Creates a cart.
* @param data - the data to create the cart with
* @return the result of the create operation
*/
async create(data: CartCreateProps): Promise<Cart | never> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepo = transactionManager.withRepository(this.cartRepository_)
const addressRepo = transactionManager.withRepository(
this.addressRepository_
)
const rawCart: DeepPartial<Cart> = {
context: data.context ?? {},
}
if (
this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key)
) {
rawCart.sales_channel_id = (
await this.getValidatedSalesChannel(data.sales_channel_id)
).id
}
if (data.customer_id) {
const customer = await this.customerService_
.withTransaction(transactionManager)
.retrieve(data.customer_id)
.catch(() => undefined)
rawCart.customer = customer
rawCart.customer_id = customer?.id
rawCart.email = customer?.email
}
if (!rawCart.email && data.email) {
const customer = await this.createOrFetchGuestCustomerFromEmail_(
data.email
)
rawCart.customer = customer
rawCart.customer_id = customer.id
rawCart.email = customer.email
}
if (!data.region_id && !data.region) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`A region_id must be provided when creating a cart`
)
}
rawCart.region_id = data.region_id
const region = data.region
? data.region
: await this.regionService_
.withTransaction(transactionManager)
.retrieve(data.region_id!, {
relations: ["countries"],
})
const regCountries = region.countries.map(({ iso_2 }) => iso_2)
if (!data.shipping_address && !data.shipping_address_id) {
if (region.countries.length === 1) {
rawCart.shipping_address = addressRepo.create({
country_code: regCountries[0],
})
}
} else {
if (data.shipping_address) {
if (!regCountries.includes(data.shipping_address.country_code!)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Shipping country not in region"
)
}
rawCart.shipping_address = data.shipping_address
}
if (data.shipping_address_id) {
const addr = await addressRepo.findOne({
where: { id: data.shipping_address_id },
})
if (
addr?.country_code &&
!regCountries.includes(addr.country_code)
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Shipping country not in region"
)
}
rawCart.shipping_address_id = data.shipping_address_id
}
}
if (data.billing_address) {
if (!regCountries.includes(data.billing_address.country_code!)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Billing country not in region"
)
}
rawCart.billing_address = data.billing_address
}
if (data.billing_address_id) {
const addr = await addressRepo.findOne({
where: { id: data.billing_address_id },
})
if (addr?.country_code && !regCountries.includes(addr.country_code)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Billing country not in region"
)
}
rawCart.billing_address_id = data.billing_address_id
}
const remainingFields: (keyof Cart)[] = [
"context",
"type",
"metadata",
"discounts",
"gift_cards",
]
for (const remainingField of remainingFields) {
if (isDefined(data[remainingField]) && remainingField !== "object") {
const key = remainingField as string
rawCart[key] = data[remainingField]
}
}
const createdCart = cartRepo.create(rawCart)
const cart = await cartRepo.save(createdCart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.CREATED, {
id: cart.id,
})
return cart
}
)
}
protected async getValidatedSalesChannel(
salesChannelId?: string
): Promise<SalesChannel | never> {
let salesChannel: SalesChannel
if (isDefined(salesChannelId)) {
salesChannel = await this.salesChannelService_
.withTransaction(this.activeManager_)
.retrieve(salesChannelId)
} else {
salesChannel = (
await this.storeService_.withTransaction(this.activeManager_).retrieve({
relations: ["default_sales_channel"],
})
).default_sales_channel
}
if (salesChannel.is_disabled) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Unable to assign the cart to a disabled Sales Channel "${salesChannel.name}"`
)
}
return salesChannel
}
/**
* Removes a line item from the cart.
* @param cartId - the id of the cart that we will remove from
* @param lineItemId - the line item to remove.
* @return the result of the update operation
*/
async removeLineItem(cartId: string, lineItemId: string): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: [
"items",
"items.variant",
"items.variant.product",
"payment_sessions",
],
})
const lineItem = cart.items.find((item) => item.id === lineItemId)
if (!lineItem) {
return cart
}
// Remove shipping methods if they are not needed
if (cart.shipping_methods?.length) {
await this.shippingOptionService_
.withTransaction(transactionManager)
.deleteShippingMethods(cart.shipping_methods)
}
const lineItemRepository = transactionManager.withRepository(
this.lineItemRepository_
)
await lineItemRepository.update(
{
id: In(cart.items.map((item) => item.id)),
},
{
has_shipping: false,
}
)
await this.lineItemService_
.withTransaction(transactionManager)
.delete(lineItem.id)
const result = await this.retrieve(cartId, {
relations: [
"items",
"items.variant",
"items.variant.product",
"discounts",
"discounts.rule",
"region",
],
})
await this.refreshAdjustments_(result)
// Notify subscribers
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, {
id: cart.id,
})
return this.retrieve(cartId)
}
)
}
/**
* Checks if a given line item has a shipping method that can fulfill it.
* Returns true if all products in the cart can be fulfilled with the current
* shipping methods.
* @param shippingMethods - the set of shipping methods to check from
* @param lineItem - the line item
* @return boolean representing whether shipping method is validated
*/
protected validateLineItemShipping_(
shippingMethods: ShippingMethod[],
lineItem: LineItem
): boolean {
if (!lineItem.variant_id) {
return true
}
if (
shippingMethods &&
shippingMethods.length &&
lineItem.variant &&
lineItem.variant.product
) {
const productProfile = lineItem.variant.product.profile_id
const selectedProfiles = shippingMethods.map(
({ shipping_option }) => shipping_option.profile_id
)
return selectedProfiles.includes(productProfile)
}
return false
}
/**
* Check if line item's variant belongs to the cart's sales channel.
*
* @param sales_channel_id - the cart for the line item
* @param lineItem - the line item being added
* @return a boolean indicating validation result
*/
protected async validateLineItem(
{ sales_channel_id }: { sales_channel_id: string | null },
lineItem: LineItemValidateData
): Promise<boolean> {
if (!sales_channel_id || !lineItem.variant_id) {
return true
}
const lineItemVariant = lineItem.variant?.product_id
? lineItem.variant
: await this.productVariantService_
.withTransaction(this.activeManager_)
.retrieve(lineItem.variant_id, { select: ["id", "product_id"] })
return !!(
await this.productService_
.withTransaction(this.activeManager_)
.filterProductsBySalesChannel(
[lineItemVariant.product_id],
sales_channel_id
)
).length
}
/**
* Adds a line item to the cart.
* @param cartId - the id of the cart that we will add to
* @param lineItem - the line item to add.
* @param config
* validateSalesChannels - should check if product belongs to the same sales chanel as cart
* (if cart has associated sales channel)
* @return the result of the update operation
* @deprecated Use {@link addOrUpdateLineItems} instead.
*/
async addLineItem(
cartId: string,
lineItem: LineItem,
config = { validateSalesChannels: true }
): Promise<void> {
const select: (keyof Cart)[] = ["id"]
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
select.push("sales_channel_id")
}
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
let cart = await this.retrieve(cartId, { select })
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
if (config.validateSalesChannels) {
if (lineItem.variant_id) {
const lineItemIsValid = await this.validateLineItem(
cart,
lineItem as LineItemValidateData
)
if (!lineItemIsValid) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The product "${lineItem.title}" must belongs to the sales channel on which the cart has been created.`
)
}
}
}
}
const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager)
let currentItem: LineItem | undefined
if (lineItem.should_merge) {
const [existingItem] = await lineItemServiceTx.list(
{
cart_id: cart.id,
variant_id: lineItem.variant_id,
should_merge: true,
},
{ take: 1, select: ["id", "metadata", "quantity"] }
)
if (
existingItem &&
isEqual(existingItem.metadata, lineItem.metadata)
) {
currentItem = existingItem
}
}
// If content matches one of the line items currently in the cart we can
// simply update the quantity of the existing line item
const quantity = currentItem
? (currentItem.quantity += lineItem.quantity)
: lineItem.quantity
// Confirm inventory or throw error
if (lineItem.variant_id) {
const isCovered =
await this.productVariantInventoryService_.confirmInventory(
lineItem.variant_id,
quantity,
{ salesChannelId: cart.sales_channel_id }
)
if (!isCovered) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Variant with id: ${lineItem.variant_id} does not have the required inventory`,
MedusaError.Codes.INSUFFICIENT_INVENTORY
)
}
}
if (currentItem) {
await lineItemServiceTx.update(currentItem.id, {
quantity: currentItem.quantity,
})
} else {
await lineItemServiceTx.create({
...lineItem,
has_shipping: false,
cart_id: cart.id,
})
}
await lineItemServiceTx
.update(
{ cart_id: cartId, has_shipping: true },
{ has_shipping: false }
)
.catch((err: Error | MedusaError) => {
// We only want to catch the errors related to not found items since we don't care if there is not item to update
if ("type" in err && err.type === MedusaError.Types.NOT_FOUND) {
return
}
throw err
})
cart = await this.retrieve(cart.id, {
relations: [
"items",
"items.variant",
"items.variant.product",
"discounts",
"discounts.rule",
"region",
],
})
await this.refreshAdjustments_(cart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, { id: cart.id })
}
)
}
/**
* Adds or update one or multiple line items to the cart. It also update all existing items in the cart
* to have has_shipping to false. Finally, the adjustments will be updated.
* @param cartId - the id of the cart that we will add to
* @param lineItems - the line items to add.
* @param config
* validateSalesChannels - should check if product belongs to the same sales chanel as cart
* (if cart has associated sales channel)
* @return the result of the update operation
*/
async addOrUpdateLineItems(
cartId: string,
lineItems: LineItem | LineItem[],
config = { validateSalesChannels: true }
): Promise<void> {
const items: LineItem[] = Array.isArray(lineItems) ? lineItems : [lineItems]
const select: (keyof Cart)[] = ["id"]
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
select.push("sales_channel_id")
}
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
let cart = await this.retrieve(cartId, { select })
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
if (config.validateSalesChannels) {
const areValid = await Promise.all(
items.map(async (item) => {
if (item.variant_id) {
return await this.validateLineItem(
cart,
item as LineItemValidateData
)
}
return true
})
)
const invalidProducts = areValid
.map((valid, index) => {
return !valid ? { title: items[index].title } : undefined
})
.filter((v): v is { title: string } => !!v)
if (invalidProducts.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The products [${invalidProducts
.map((item) => item.title)
.join(
" - "
)}] must belongs to the sales channel on which the cart has been created.`
)
}
}
}
const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager)
const productVariantInventoryServiceTx =
this.productVariantInventoryService_.withTransaction(
transactionManager
)
const existingItems = await lineItemServiceTx.list(
{
cart_id: cart.id,
variant_id: In([items.map((item) => item.variant_id)]),
should_merge: true,
},
{ select: ["id", "metadata", "quantity"] }
)
const existingItemsVariantMap = new Map()
existingItems.forEach((item) => {
existingItemsVariantMap.set(item.variant_id, item)
})
const lineItemsToCreate: LineItem[] = []
const lineItemsToUpdate: { [id: string]: LineItem }[] = []
for (const item of items) {
let currentItem: LineItem | undefined
const existingItem = existingItemsVariantMap.get(item.variant_id)
if (item.should_merge) {
if (existingItem && isEqual(existingItem.metadata, item.metadata)) {
currentItem = existingItem
}
}
// If content matches one of the line items currently in the cart we can
// simply update the quantity of the existing line item
item.quantity = currentItem
? (currentItem.quantity += item.quantity)
: item.quantity
if (item.variant_id) {
const isSufficient =
await productVariantInventoryServiceTx.confirmInventory(
item.variant_id,
item.quantity,
{ salesChannelId: cart.sales_channel_id }
)
if (!isSufficient) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Variant with id: ${item.variant_id} does not have the required inventory`,
MedusaError.Codes.INSUFFICIENT_INVENTORY
)
}
}
if (currentItem) {
lineItemsToUpdate[currentItem.id] = {
quantity: item.quantity,
has_shipping: false,
}
} else {
// Since the variant is eager loaded, we are removing it before the line item is being created.
delete (item as Partial<LineItem>).variant
item.has_shipping = false
item.cart_id = cart.id
lineItemsToCreate.push(item)
}
}
const itemKeysToUpdate = Object.keys(lineItemsToUpdate)
// Update all items that needs to be updated
if (itemKeysToUpdate.length) {
await Promise.all(
itemKeysToUpdate.map(async (id) => {
return await lineItemServiceTx.update(id, lineItemsToUpdate[id])
})
)
}
// Create all items that needs to be created
await lineItemServiceTx.create(lineItemsToCreate)
await lineItemServiceTx
.update(
{
cart_id: cartId,
has_shipping: true,
},
{ has_shipping: false }
)
.catch((err: Error | MedusaError) => {
// We only want to catch the errors related to not found items since we don't care if there is not item to update
if ("type" in err && err.type === MedusaError.Types.NOT_FOUND) {
return
}
throw err
})
cart = await this.retrieve(cart.id, {
relations: [
"items",
"items.variant",
"items.variant.product",
"discounts",
"discounts.rule",
"region",
],
})
await this.refreshAdjustments_(cart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, { id: cart.id })
}
)
}
/**
* Updates a cart's existing line item.
* @param cartId - the id of the cart to update
* @param lineItemId - the id of the line item to update.
* @param lineItemUpdate - the line item to update. Must include an id field.
* @return the result of the update operation
*/
async updateLineItem(
cartId: string,
lineItemId: string,
lineItemUpdate: LineItemUpdate
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const lineItem = await this.lineItemService_.retrieve(lineItemId, {
select: ["id", "quantity", "variant_id", "cart_id"],
})
if (lineItem.cart_id !== cartId) {
// Ensure that the line item exists in the cart
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"A line item with the provided id doesn't exist in the cart"
)
}
if (lineItemUpdate.quantity) {
if (lineItem.variant_id) {
const select: (keyof Cart)[] = ["id"]
if (
this.featureFlagRouter_.isFeatureEnabled(
SalesChannelFeatureFlag.key
)
) {
select.push("sales_channel_id")
}
const cart = await this.retrieve(cartId, { select: select })
const hasInventory =
await this.productVariantInventoryService_.confirmInventory(
lineItem.variant_id,
lineItemUpdate.quantity,
{ salesChannelId: cart.sales_channel_id }
)
if (!hasInventory) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Inventory doesn't cover the desired quantity"
)
}
}
}
await this.lineItemService_
.withTransaction(transactionManager)
.update(lineItemId, lineItemUpdate)
const updatedCart = await this.retrieve(cartId, {
relations: [
"items",
"items.variant",
"items.variant.product",
"discounts",
"discounts.rule",
"region",
],
})
await this.refreshAdjustments_(updatedCart)
// Update the line item
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* Ensures shipping total on cart is correct in regards to a potential free
* shipping discount
* If a free shipping is present, we set shipping methods price to 0
* if a free shipping was present, we set shipping methods to original amount
* @param cart - the the cart to adjust free shipping for
* @param shouldAdd - flag to indicate, if we should add or remove
* @return void
*/
protected async adjustFreeShipping_(
cart: Cart,
shouldAdd: boolean
): Promise<void> {
if (cart.shipping_methods?.length) {
const shippingMethodRepository = this.activeManager_.withRepository(
this.shippingMethodRepository_
)
// if any free shipping discounts, we ensure to update shipping method amount
if (shouldAdd) {
await shippingMethodRepository.update(
{
id: In(
cart.shipping_methods.map((shippingMethod) => shippingMethod.id)
),
},
{
price: 0,
}
)
} else {
await Promise.all(
cart.shipping_methods.map(async (shippingMethod) => {
// if free shipping discount is removed, we adjust the shipping
// back to its original amount
// if shipping option amount is null, we assume the option is calculated
shippingMethod.price =
shippingMethod.shipping_option.amount ??
(await this.shippingOptionService_.getPrice_(
shippingMethod.shipping_option,
shippingMethod.data,
cart
))
return shippingMethodRepository.save(shippingMethod)
})
)
}
}
}
async update(cartId: string, data: CartUpdateProps): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepo = transactionManager.withRepository(this.cartRepository_)
const relations = [
"items",
"items.variant",
"items.variant.product",
"shipping_methods",
"shipping_address",
"billing_address",
"gift_cards",
"customer",
"region",
"payment_sessions",
"region.countries",
"discounts",
"discounts.rule",
"discounts.regions",
]
if (
this.featureFlagRouter_.isFeatureEnabled(
SalesChannelFeatureFlag.key
) &&
data.sales_channel_id
) {
relations.push("items.variant", "items.variant.product")
}
const cart = await this.retrieve(cartId, {
relations,
})
const originalCartCustomer = { ...(cart.customer ?? {}) }
if (data.customer_id) {
await this.updateCustomerId_(cart, data.customer_id)
} else if (isDefined(data.email)) {
const customer = await this.createOrFetchGuestCustomerFromEmail_(
data.email
)
cart.customer = customer
cart.customer_id = customer.id
cart.email = customer.email
}
if (isDefined(data.customer_id) || isDefined(data.region_id)) {
await this.updateUnitPrices_(cart, data.region_id, data.customer_id)
}
if (isDefined(data.region_id)) {
const shippingAddress =
typeof data.shipping_address !== "string"
? data.shipping_address
: {}
const countryCode =
(data.country_code || shippingAddress?.country_code) ?? null
await this.setRegion_(cart, data.region_id, countryCode)
}
const addrRepo = transactionManager.withRepository(
this.addressRepository_
)
const billingAddress = data.billing_address_id ?? data.billing_address
if (billingAddress !== undefined) {
await this.updateBillingAddress_(cart, billingAddress, addrRepo)
}
const shippingAddress =
data.shipping_address_id ?? data.shipping_address
if (shippingAddress !== undefined) {
await this.updateShippingAddress_(cart, shippingAddress, addrRepo)
}
if (
this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key)
) {
if (
isDefined(data.sales_channel_id) &&
data.sales_channel_id != cart.sales_channel_id
) {
await this.onSalesChannelChange(cart, data.sales_channel_id)
cart.sales_channel_id = data.sales_channel_id
}
}
if (isDefined(data.discounts) && data.discounts.length) {
const previousDiscounts = [...cart.discounts]
cart.discounts.length = 0
await this.applyDiscounts(
cart,
data.discounts.map((d) => d.code)
)
const hasFreeShipping = cart.discounts.some(
({ rule }) => rule?.type === DiscountRuleType.FREE_SHIPPING
)
// if we previously had a free shipping discount and then removed it,
// we need to update shipping methods to original price
if (
previousDiscounts.some(
({ rule }) => rule.type === DiscountRuleType.FREE_SHIPPING
) &&
!hasFreeShipping
) {
await this.adjustFreeShipping_(cart, false)
}
if (hasFreeShipping) {
await this.adjustFreeShipping_(cart, true)
}
} else if (isDefined(data.discounts) && !data.discounts.length) {
cart.discounts.length = 0
await this.refreshAdjustments_(cart)
}
if ("gift_cards" in data) {
cart.gift_cards = []
await Promise.all(
(data.gift_cards ?? []).map(async ({ code }) => {
return this.applyGiftCard_(cart, code)
})
)
}
if (data?.metadata) {
cart.metadata = setMetadata(cart, data.metadata)
}
if ("context" in data) {
const prevContext = cart.context || {}
cart.context = {
...prevContext,
...data.context,
}
}
if ("completed_at" in data) {
cart.completed_at = data.completed_at!
}
if ("payment_authorized_at" in data) {
cart.payment_authorized_at = data.payment_authorized_at!
}
const updatedCart = await cartRepo.save(cart)
if (
(data.email && data.email !== originalCartCustomer.email) ||
(data.customer_id && data.customer_id !== originalCartCustomer.id)
) {
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.CUSTOMER_UPDATED, updatedCart.id)
}
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* Remove the cart line item that does not belongs to the newly assigned sales channel
*
* @param cart - The cart being updated
* @param newSalesChannelId - The new sales channel being assigned to the cart
* @return void
* @protected
*/
protected async onSalesChannelChange(
cart: Cart,
newSalesChannelId: string
): Promise<void> {
await this.getValidatedSalesChannel(newSalesChannelId)
const productIds = cart.items.map((item) => item.variant.product_id)
const productsToKeep = await this.productService_
.withTransaction(this.activeManager_)
.filterProductsBySalesChannel(productIds, newSalesChannelId, {
select: ["id"],
take: productIds.length,
})
const productIdsToKeep = new Set<string>(
productsToKeep.map((product) => product.id)
)
const itemsToRemove = cart.items.filter((item) => {
return !productIdsToKeep.has(item.variant.product_id)
})
if (itemsToRemove.length) {
const results = await Promise.all(
itemsToRemove.map(async (item) => {
return this.removeLineItem(cart.id, item.id)
})
)
cart.items = results.pop()?.items ?? []
}
}
/**
* Sets the customer id of a cart
* @param cart - the cart to add email to
* @param customerId - the customer to add to cart
* @return the result of the update operation
*/
protected async updateCustomerId_(
cart: Cart,
customerId: string
): Promise<void> {
const customer = await this.customerService_
.withTransaction(this.activeManager_)
.retrieve(customerId)
cart.customer = customer
cart.customer_id = customer.id
cart.email = customer.email
}
/**
* Creates or fetches a user based on an email.
* @param email - the email to use
* @return the resultign customer object
*/
protected async createOrFetchGuestCustomerFromEmail_(
email: string
): Promise<Customer> {
const validatedEmail = validateEmail(email)
const customerServiceTx = this.customerService_.withTransaction(
this.activeManager_
)
let customer = await customerServiceTx
.retrieveUnregisteredByEmail(validatedEmail)
.catch(() => undefined)
if (!customer) {
customer = await customerServiceTx.create({ email: validatedEmail })
}
return customer
}
/**
* Updates the cart's billing address.
* @param cart - the cart to update
* @param addressOrId - the value to set the billing address to
* @param addrRepo - the repository to use for address updates
* @return the result of the update operation
*/
protected async updateBillingAddress_(
cart: Cart,
addressOrId: AddressPayload | Partial<Address> | string,
addrRepo: typeof AddressRepository
): Promise<void> {
let address: Address
if (typeof addressOrId === `string`) {
address = (await addrRepo.findOne({
where: { id: addressOrId },
})) as Address
} else {
address = addressOrId as Address
}
if (address.id) {
cart.billing_address = await addrRepo.save(address)
} else {
if (cart.billing_address_id) {
const addr = await addrRepo.findOne({
where: { id: cart.billing_address_id },
})
await addrRepo.save({ ...addr, ...address })
} else {
cart.billing_address = addrRepo.create({
...address,
})
}
}
}
/**
* Updates the cart's shipping address.
* @param cart - the cart to update
* @param addressOrId - the value to set the shipping address to
* @param addrRepo - the repository to use for address updates
* @return the result of the update operation
*/
protected async updateShippingAddress_(
cart: Cart,
addressOrId: AddressPayload | Partial<Address> | string,
addrRepo: typeof AddressRepository
): Promise<void> {
let address: Address
if (addressOrId === null) {
cart.shipping_address = null
return
}
if (typeof addressOrId === `string`) {
address = (await addrRepo.findOne({
where: { id: addressOrId },
})) as Address
} else {
address = addressOrId as Address
}
if (
address.country_code &&
!cart.region.countries.find(
({ iso_2 }) => address.country_code?.toLowerCase() === iso_2
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Shipping country must be in the cart region"
)
}
if (address.id) {
cart.shipping_address = await addrRepo.save(address)
} else {
if (cart.shipping_address_id) {
const addr = await addrRepo.findOne({
where: { id: cart.shipping_address_id },
})
await addrRepo.save({ ...addr, ...address })
} else {
cart.shipping_address = addrRepo.create({
...address,
})
}
}
}
protected async applyGiftCard_(cart: Cart, code: string): Promise<void> {
const giftCard = await this.giftCardService_
.withTransaction(this.activeManager_)
.retrieveByCode(code)
if (giftCard.is_disabled) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The gift card is disabled"
)
}
if (giftCard.region_id !== cart.region_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The gift card cannot be used in the current region"
)
}
// if discount is already there, we simply resolve
if (cart.gift_cards.find(({ id }) => id === giftCard.id)) {
return
}
cart.gift_cards = [...cart.gift_cards, giftCard]
}
/**
* Updates the cart's discounts.
* If discount besides free shipping is already applied, this
* will be overwritten
* Throws if discount regions does not include the cart region
* @param cart - the cart to update
* @param discountCode - the discount code
*/
async applyDiscount(cart: Cart, discountCode: string): Promise<void> {
return await this.applyDiscounts(cart, [discountCode])
}
/**
* Updates the cart's discounts.
* If discount besides free shipping is already applied, this
* will be overwritten
* Throws if discount regions does not include the cart region
* @param cart - the cart to update
* @param discountCodes - the discount code(s) to apply
*/
async applyDiscounts(cart: Cart, discountCodes: string[]): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const discounts = await this.discountService_
.withTransaction(transactionManager)
.listByCodes(discountCodes, { relations: ["rule", "regions"] })
await this.discountService_
.withTransaction(transactionManager)
.validateDiscountForCartOrThrow(cart, discounts)
const rules: Map<string, DiscountRule> = new Map()
const discountsMap = new Map(
discounts.map((d) => {
rules.set(d.id, d.rule)
return [d.id, d]
})
)
cart.discounts.forEach((discount) => {
if (discountsMap.has(discount.id)) {
discountsMap.delete(discount.id)
}
})
const toParse = [...cart.discounts, ...discountsMap.values()]
let sawNotShipping = false
const newDiscounts = toParse.map((discountToParse) => {
switch (discountToParse.rule?.type) {
case DiscountRuleType.FREE_SHIPPING:
if (
discountToParse.rule.type ===
rules.get(discountToParse.id)!.type
) {
return discountsMap.get(discountToParse.id)
}
return discountToParse
default:
if (!sawNotShipping) {
sawNotShipping = true
if (
rules.get(discountToParse.id)!.type !==
DiscountRuleType.FREE_SHIPPING
) {
return discountsMap.get(discountToParse.id)
}
return discountToParse
}
return null
}
})
cart.discounts = newDiscounts.filter(
(newDiscount): newDiscount is Discount => {
return !!newDiscount
}
)
const hadNonFreeShippingDiscounts = [...rules.values()].some(
(rule) => rule.type !== DiscountRuleType.FREE_SHIPPING
)
if (hadNonFreeShippingDiscounts && cart?.items) {
await this.refreshAdjustments_(cart)
}
}
)
}
/**
* Removes a discount based on a discount code.
* @param cartId - the id of the cart to remove from
* @param discountCode - the discount code to remove
* @return the resulting cart
*/
async removeDiscount(cartId: string, discountCode: string): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: [
"items",
"items.variant",
"items.variant.product",
"region",
"discounts",
"discounts.rule",
"payment_sessions",
"shipping_methods",
],
})
if (
cart.discounts.some(
({ rule }) => rule.type === DiscountRuleType.FREE_SHIPPING
)
) {
await this.adjustFreeShipping_(cart, false)
}
cart.discounts = cart.discounts.filter(
(discount) => discount.code !== discountCode
)
const cartRepo = transactionManager.withRepository(this.cartRepository_)
const updatedCart = await cartRepo.save(cart)
await this.refreshAdjustments_(updatedCart)
if (cart.payment_sessions?.length) {
await this.setPaymentSessions(cartId)
}
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* Updates the currently selected payment session.
* @param cartId - the id of the cart to update the payment session for
* @param update - the data to update the payment session with
* @return the resulting cart
*/
async updatePaymentSession(
cartId: string,
update: Record<string, unknown>
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: ["payment_sessions"],
})
if (cart.payment_session) {
await this.paymentProviderService_
.withTransaction(transactionManager)
.updateSessionData(cart.payment_session, update)
}
const updatedCart = await this.retrieve(cart.id)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* Authorizes a payment for a cart.
* Will authorize with chosen payment provider. This will return
* a payment object, that we will use to update our cart payment with.
* Additionally, if the payment does not require more or fails, we will
* set the payment on the cart.
* @param cartId - the id of the cart to authorize payment for
* @param context - object containing whatever is relevant for
* authorizing the payment with the payment provider. As an example,
* this could be IP address or similar for fraud handling.
* @return the resulting cart
*/
async authorizePayment(
cartId: string,
context: Record<string, unknown> & {
cart_id: string
} = { cart_id: "" }
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepository = transactionManager.withRepository(
this.cartRepository_
)
const cart = await this.retrieveWithTotals(cartId, {
relations: [
"payment_sessions",
"items",
"items.variant",
"items.variant.product",
],
})
// If cart total is 0, we don't perform anything payment related
if (cart.total! <= 0) {
cart.payment_authorized_at = new Date()
await cartRepository.save({
id: cart.id,
payment_authorized_at: cart.payment_authorized_at,
})
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, cart)
return cart
}
if (!cart.payment_session) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"You cannot complete a cart without a payment session."
)
}
const session = (await this.paymentProviderService_
.withTransaction(transactionManager)
.authorizePayment(cart.payment_session, context)) as PaymentSession
const freshCart = (await this.retrieve(cart.id, {
relations: ["payment_sessions"],
})) as Cart & { payment_session: PaymentSession }
if (session.status === "authorized") {
freshCart.payment = await this.paymentProviderService_
.withTransaction(transactionManager)
.createPayment({
cart_id: cart.id,
currency_code: cart.region.currency_code,
amount: cart.total!,
payment_session: freshCart.payment_session,
})
freshCart.payment_authorized_at = new Date()
}
const updatedCart = await cartRepository.save(freshCart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* Selects a payment session for a cart and creates a payment object in the external provider system
* @param cartId - the id of the cart to add payment method to
* @param providerId - the id of the provider to be set to the cart
*/
async setPaymentSession(cartId: string, providerId: string): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const psRepo = transactionManager.withRepository(
this.paymentSessionRepository_
)
const cart = await this.retrieveWithTotals(cartId, {
relations: [
"items",
"items.variant",
"items.variant.product",
"customer",
"region",
"region.payment_providers",
"payment_sessions",
],
})
const isProviderPresent = cart.region.payment_providers.find(
({ id }) => providerId === id
)
if (providerId !== "system" && !isProviderPresent) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`The payment method is not available in this region`
)
}
let currentlySelectedSession = cart.payment_sessions.find(
(s) => s.is_selected
)
if (
currentlySelectedSession &&
currentlySelectedSession.provider_id !== providerId
) {
const psRepo = transactionManager.withRepository(
this.paymentSessionRepository_
)
if (currentlySelectedSession.is_initiated) {
await this.paymentProviderService_
.withTransaction(transactionManager)
.deleteSession(currentlySelectedSession)
currentlySelectedSession = psRepo.create(currentlySelectedSession)
}
currentlySelectedSession.is_initiated = false
currentlySelectedSession.is_selected = false
await psRepo.save(currentlySelectedSession)
}
const cartPaymentSessionIds = cart.payment_sessions.map((p) => p.id)
await psRepo.update(
{ id: In(cartPaymentSessionIds) },
{
is_selected: null,
is_initiated: false,
}
)
let paymentSession = cart.payment_sessions.find(
(ps) => ps.provider_id === providerId
)
if (!paymentSession) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Could not find payment session"
)
}
const sessionInput: PaymentSessionInput = {
cart,
customer: cart.customer,
amount: cart.total!,
currency_code: cart.region.currency_code,
provider_id: providerId,
payment_session_id: paymentSession.id,
}
if (paymentSession.is_initiated) {
// update the session remotely
await this.paymentProviderService_
.withTransaction(transactionManager)
.updateSession(paymentSession, sessionInput)
} else {
// Create the session remotely
paymentSession = await this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(sessionInput)
}
await psRepo.update(paymentSession.id, {
is_selected: true,
is_initiated: true,
})
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, { id: cartId })
},
"SERIALIZABLE"
)
}
/**
* Creates, updates and sets payment sessions associated with the cart. The
* first time the method is called payment sessions will be created for each
* provider. Additional calls will ensure that payment sessions have correct
* amounts, currencies, etc. as well as make sure to filter payment sessions
* that are not available for the cart's region.
* @param cartOrCartId - the id of the cart to set payment session for
* @return the result of the update operation.
*/
async setPaymentSessions(cartOrCartId: Cart | string): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const psRepo = transactionManager.withRepository(
this.paymentSessionRepository_
)
const paymentProviderServiceTx =
this.paymentProviderService_.withTransaction(transactionManager)
const cartId =
typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id
const cart = await this.retrieveWithTotals(
cartId,
{
relations: [
"items",
"items.variant",
"items.variant.product",
"items.adjustments",
"discounts",
"discounts.rule",
"gift_cards",
"shipping_methods",
"billing_address",
"shipping_address",
"region",
"region.tax_rates",
"region.payment_providers",
"payment_sessions",
"customer",
],
},
{ force_taxes: true }
)
const { total, region } = cart
// Helpers that either delete a session locally or remotely. Will be used in multiple places below.
const deleteSessionAppropriately = async (session) => {
if (session.is_initiated) {
return paymentProviderServiceTx.deleteSession(session)
}
return psRepo.delete(session)
}
// In the case of a cart that has a total <= 0 we can return prematurely.
// we are deleting the sessions, and we don't need to create or update anything from now on.
if (total <= 0) {
await Promise.all(
cart.payment_sessions.map(async (session) => {
return deleteSessionAppropriately(session)
})
)
return
}
const providerSet = new Set(region.payment_providers.map((p) => p.id))
const alreadyConsumedProviderIds: Set<string> = new Set()
const partialSessionInput: Omit<PaymentSessionInput, "provider_id"> = {
cart: cart as Cart,
customer: cart.customer,
amount: total,
currency_code: cart.region.currency_code,
}
const partialPaymentSessionData = {
cart_id: cartId,
data: {},
status: PaymentSessionStatus.PENDING,
amount: total,
}
await Promise.all(
cart.payment_sessions.map(async (session) => {
if (!providerSet.has(session.provider_id)) {
/**
* if the provider does not belong to the region then delete the session.
* The deletion occurs locally if the session is not initiated
* otherwise the deletion will also occur remotely through the external provider.
*/
return await deleteSessionAppropriately(session)
}
/**
* if the provider belongs to the region then update or delete the session.
* The update occurs locally if it is not selected
* otherwise the update will also occur remotely through the external provider.
*/
// We are saving the provider id on which the work below will be done. That way,
// when handling the providers from the cart region at a later point below, we do not double the work on the sessions that already
// exists for the same provider.
alreadyConsumedProviderIds.add(session.provider_id)
// Update remotely
if (session.is_selected && session.is_initiated) {
const paymentSessionInput = {
...partialSessionInput,
provider_id: session.provider_id,
}
return paymentProviderServiceTx.updateSession(
session,
paymentSessionInput
)
}
let updatedSession: PaymentSession
// At this stage the session is not selected. Delete it remotely if there is some
// external provider data and create the session locally only. Otherwise, update the existing local session.
if (session.is_initiated) {
await paymentProviderServiceTx.deleteSession(session)
updatedSession = psRepo.create({
...partialPaymentSessionData,
is_initiated: false,
provider_id: session.provider_id,
})
} else {
updatedSession = { ...session, amount: total } as PaymentSession
}
return psRepo.save(updatedSession)
})
)
/**
* From now on, the sessions have been cleanup. We can now
* - Set the provider session as selected if it is the only one existing and there is no payment session on the cart
* - Create a session per provider locally if it does not already exists on the cart as per the previous step
*/
// If only one provider exists and there is no session on the cart, create the session and select it.
if (region.payment_providers.length === 1 && !cart.payment_session) {
const paymentProvider = region.payment_providers[0]
const paymentSessionInput = {
...partialSessionInput,
provider_id: paymentProvider.id,
}
const paymentSession = await this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(paymentSessionInput)
await psRepo.update(paymentSession.id, {
is_selected: true,
is_initiated: true,
})
return
}
await Promise.all(
region.payment_providers.map(async (paymentProvider) => {
if (alreadyConsumedProviderIds.has(paymentProvider.id)) {
return
}
const paymentSession = psRepo.create({
...partialPaymentSessionData,
provider_id: paymentProvider.id,
})
return psRepo.save(paymentSession)
})
)
}
)
}
/**
* Removes a payment session from the cart.
* @param cartId - the id of the cart to remove from
* @param providerId - the id of the provider whoose payment session
* should be removed.
* @return the resulting cart.
*/
async deletePaymentSession(
cartId: string,
providerId: string
): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: ["payment_sessions"],
})
const cartRepo = transactionManager.withRepository(this.cartRepository_)
if (cart.payment_sessions) {
const paymentSession = cart.payment_sessions.find(
({ provider_id }) => provider_id === providerId
)
cart.payment_sessions = cart.payment_sessions.filter(
({ provider_id }) => provider_id !== providerId
)
const psRepo = transactionManager.withRepository(
this.paymentSessionRepository_
)
if (paymentSession) {
if (paymentSession.is_selected || paymentSession.is_initiated) {
await this.paymentProviderService_
.withTransaction(transactionManager)
.deleteSession(paymentSession)
} else {
await psRepo.delete({ id: paymentSession.id })
}
}
}
await cartRepo.save(cart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, { id: cart.id })
}
)
}
/**
* Refreshes a payment session on a cart
* @param cartId - the id of the cart to remove from
* @param providerId - the id of the provider whoose payment session
* should be removed.
* @return {Promise<void>} the resulting cart.
*/
async refreshPaymentSession(
cartId: string,
providerId: string
): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieveWithTotals(cartId, {
relations: ["payment_sessions"],
})
if (cart.payment_sessions) {
const paymentSession = cart.payment_sessions.find(
({ provider_id }) => provider_id === providerId
)
if (paymentSession) {
if (paymentSession.is_selected) {
await this.paymentProviderService_
.withTransaction(transactionManager)
.refreshSession(paymentSession, {
cart: cart as Cart,
customer: cart.customer,
amount: cart.total,
currency_code: cart.region.currency_code,
provider_id: providerId,
})
} else {
const psRepo = transactionManager.withRepository(
this.paymentSessionRepository_
)
await psRepo.update(paymentSession.id, {
amount: cart.total,
})
}
}
}
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, { id: cartId })
}
)
}
/**
* Adds the shipping method to the list of shipping methods associated with
* the cart. Shipping Methods are the ways that an order is shipped, whereas a
* Shipping Option is a possible way to ship an order. Shipping Methods may
* also have additional details in the data field such as an id for a package
* shop.
* @param cartId - the id of the cart to add shipping method to
* @param optionId - id of shipping option to add as valid method
* @param data - the fulmillment data for the method
* @return the result of the update operation
*/
async addShippingMethod(
cartId: string,
optionId: string,
data: Record<string, unknown> = {}
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieveWithTotals(cartId, {
relations: [
"shipping_methods",
"shipping_methods.shipping_option",
"items",
"items.variant",
"items.variant.product",
"payment_sessions",
],
})
const cartCustomShippingOptions =
await this.customShippingOptionService_
.withTransaction(transactionManager)
.list({ cart_id: cart.id })
const customShippingOption = this.findCustomShippingOption(
cartCustomShippingOptions,
optionId
)
const { shipping_methods } = cart
/**
* If we have a custom shipping option configured we want the price
* override to take effect and do not want `validateCartOption` to check
* if requirements are met, hence we are not passing the entire cart, but
* just the id.
*/
const shippingMethodConfig = customShippingOption
? { cart_id: cart.id, price: customShippingOption.price }
: { cart }
const newShippingMethod = await this.shippingOptionService_
.withTransaction(transactionManager)
.createShippingMethod(optionId, data, shippingMethodConfig)
const methods = [newShippingMethod]
if (shipping_methods?.length) {
const shippingOptionServiceTx =
this.shippingOptionService_.withTransaction(transactionManager)
for (const shippingMethod of shipping_methods) {
if (
shippingMethod.shipping_option.profile_id ===
newShippingMethod.shipping_option.profile_id
) {
await shippingOptionServiceTx.deleteShippingMethods(
shippingMethod
)
} else {
methods.push(shippingMethod)
}
}
}
if (cart.items?.length) {
const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager)
await Promise.all(
cart.items.map(async (item) => {
return lineItemServiceTx.update(item.id, {
has_shipping: this.validateLineItemShipping_(methods, item),
})
})
)
}
const updatedCart = await this.retrieve(cartId, {
relations: ["discounts", "discounts.rule", "shipping_methods"],
})
// if cart has freeshipping, adjust price
if (
updatedCart.discounts.some(
({ rule }) => rule.type === DiscountRuleType.FREE_SHIPPING
)
) {
await this.adjustFreeShipping_(updatedCart, true)
}
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
},
"SERIALIZABLE"
)
}
/**
* Finds the cart's custom shipping options based on the passed option id.
* throws if custom options is not empty and no shipping option corresponds to optionId
* @param cartCustomShippingOptions - the cart's custom shipping options
* @param optionId - id of the normal or custom shipping option to find in the cartCustomShippingOptions
* @return custom shipping option
*/
findCustomShippingOption(
cartCustomShippingOptions: CustomShippingOption[],
optionId: string
): CustomShippingOption | undefined {
const customOption = cartCustomShippingOptions?.find(
(cso) => cso.shipping_option_id === optionId
)
const hasCustomOptions = cartCustomShippingOptions?.length
if (hasCustomOptions && !customOption) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Wrong shipping option"
)
}
return customOption
}
protected async updateUnitPrices_(
cart: Cart,
regionId?: string,
customer_id?: string
): Promise<void> {
// If the cart contains items, we update the price of the items
// to match the updated region or customer id (keeping the old
// value if it exists)
if (cart.items?.length) {
const region = await this.regionService_
.withTransaction(this.activeManager_)
.retrieve(regionId || cart.region_id, {
relations: ["countries"],
})
const lineItemServiceTx = this.lineItemService_.withTransaction(
this.activeManager_
)
cart.items = (
await Promise.all(
cart.items.map(async (item) => {
if (!item.variant_id) {
return item
}
const availablePrice = await this.priceSelectionStrategy_
.withTransaction(this.activeManager_)
.calculateVariantPrice(item.variant_id, {
region_id: region.id,
currency_code: region.currency_code,
quantity: item.quantity,
customer_id: customer_id || cart.customer_id,
include_discount_prices: true,
})
.catch(() => undefined)
if (
availablePrice !== undefined &&
availablePrice.calculatedPrice !== null
) {
await lineItemServiceTx.update(item.id, {
has_shipping: false,
unit_price: availablePrice.calculatedPrice,
})
return await lineItemServiceTx.retrieve(item.id, {
relations: ["variant", "variant.product"],
})
}
return await lineItemServiceTx.delete(item.id)
})
)
)
.flat()
.filter((item): item is LineItem => !!item)
}
}
/**
* Set's the region of a cart.
* @param cart - the cart to set region on
* @param regionId - the id of the region to set the region to
* @param countryCode - the country code to set the country to
* @return the result of the update operation
*/
protected async setRegion_(
cart: Cart,
regionId: string,
countryCode: string | null
): Promise<void> {
if (cart.completed_at || cart.payment_authorized_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot change the region of a completed cart"
)
}
const region = await this.regionService_
.withTransaction(this.activeManager_)
.retrieve(regionId, {
relations: ["countries"],
})
cart.region = region
cart.region_id = region.id
const addrRepo = this.activeManager_.withRepository(this.addressRepository_)
/*
* When changing the region you are changing the set of countries that your
* cart can be shipped to so we need to make sure that the current shipping
* address adheres to the new country set.
*
* First check if there is an existing shipping address on the cart if so
* fetch the entire thing so we can modify the shipping country
*/
let shippingAddress: Partial<Address> = {}
if (cart.shipping_address_id) {
shippingAddress = (await addrRepo.findOne({
where: { id: cart.shipping_address_id },
})) as Address
}
/*
* If the client has specified which country code we are updating to check
* that that country is in fact in the country and perform the update.
*/
if (countryCode !== null) {
if (
!region.countries.find(
({ iso_2 }) => iso_2 === countryCode.toLowerCase()
)
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Country not available in region`
)
}
const updated = addrRepo.create({
...shippingAddress,
country_code: countryCode.toLowerCase(),
})
await addrRepo.save(updated)
await this.updateShippingAddress_(cart, updated, addrRepo)
} else {
/*
* In the case where the country code is not specified we need to check
*
* 1. if the region we are switching to has only one country preselect
* that
* 2. if the region has multiple countries we need to unset the country
* and wait for client to decide which country to use
*/
let updated = { ...shippingAddress }
// If the country code of a shipping address is set we need to clear it
if (!isEmpty(shippingAddress) && shippingAddress.country_code) {
updated = {
...updated,
country_code: null,
}
}
// If there is only one country in the region preset it
if (region.countries.length === 1) {
updated = {
...updated,
country_code: region.countries[0].iso_2,
}
}
await this.updateShippingAddress_(cart, updated, addrRepo)
}
// Shipping methods are determined by region so the user needs to find a
// new shipping method
if (cart.shipping_methods && cart.shipping_methods.length) {
await this.shippingOptionService_
.withTransaction(this.activeManager_)
.deleteShippingMethods(cart.shipping_methods)
}
if (cart.discounts && cart.discounts.length) {
cart.discounts = cart.discounts.filter((discount) => {
return discount.regions.find(({ id }) => id === regionId)
})
}
if (cart?.items?.length) {
// line item adjustments should be refreshed on region change after having filtered out inapplicable discounts
await this.refreshAdjustments_(cart)
}
cart.gift_cards = []
if (cart.payment_sessions && cart.payment_sessions.length) {
const paymentSessionRepo = this.activeManager_.withRepository(
this.paymentSessionRepository_
)
await paymentSessionRepo.delete({
id: In(
cart.payment_sessions.map((paymentSession) => paymentSession.id)
),
})
cart.payment_sessions.length = 0
cart.payment_session = null
}
}
/**
* Deletes a cart from the database. Completed carts cannot be deleted.
* @param cartId - the id of the cart to delete
* @return the deleted cart or undefined if the cart was not found.
*/
async delete(cartId: string): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: [
"items",
"items.variant",
"items.variant.product",
"discounts",
"discounts.rule",
"payment_sessions",
],
})
if (cart.completed_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Completed carts cannot be deleted"
)
}
if (cart.payment_authorized_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't delete a cart with an authorized payment"
)
}
const cartRepo = transactionManager.withRepository(this.cartRepository_)
return cartRepo.remove(cart)
}
)
}
/**
* Dedicated method to set metadata for a cart.
* To ensure that plugins does not overwrite each
* others metadata fields, setMetadata is provided.
* @param cartId - the cart to apply metadata to.
* @param key - key for metadata field
* @param value - value for metadata field.
* @return resolves to the updated result.
*/
async setMetadata(
cartId: string,
key: string,
value: string | number
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepo = transactionManager.withRepository(this.cartRepository_)
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
const cart = await cartRepo.findOne({ where: { id: cartId } })
if (!cart) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
"Unable to find the cart with the given id"
)
}
const existing = cart.metadata || {}
cart.metadata = {
...existing,
[key]: value,
}
const updatedCart = await cartRepo.save(cart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
async createTaxLines(cartOrId: string | Cart): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = isCart(cartOrId)
? cartOrId
: await this.retrieve(cartOrId, {
relations: [
"customer",
"discounts",
"discounts.rule",
"gift_cards",
"items",
"items.variant",
"items.variant.product",
"items.adjustments",
"region",
"region.tax_rates",
"shipping_address",
"shipping_methods",
],
})
const calculationContext = await this.totalsService_
.withTransaction(transactionManager)
.getCalculationContext(cart)
await this.taxProviderService_
.withTransaction(transactionManager)
.createTaxLines(cart, calculationContext)
}
)
}
async deleteTaxLines(id: string): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(id, {
relations: [
"items",
"items.tax_lines",
"shipping_methods",
"shipping_methods.tax_lines",
],
})
await transactionManager.remove(cart.items.flatMap((i) => i.tax_lines))
await transactionManager.remove(
cart.shipping_methods.flatMap((s) => s.tax_lines)
)
}
)
}
async decorateTotals(
cart: Cart,
totalsConfig: TotalsConfig = {}
): Promise<WithRequiredProperty<Cart, "total">> {
const newTotalsServiceTx = this.newTotalsService_.withTransaction(
this.activeManager_
)
const calculationContext = await this.totalsService_.getCalculationContext(
cart
)
const includeTax = totalsConfig?.force_taxes || cart.region?.automatic_taxes
const cartItems = [...(cart.items ?? [])]
const cartShippingMethods = [...(cart.shipping_methods ?? [])]
if (includeTax) {
const taxLinesMaps = await this.taxProviderService_
.withTransaction(this.activeManager_)
.getTaxLinesMap(cartItems, calculationContext)
cartItems.forEach((item) => {
if (item.is_return) {
return
}
item.tax_lines = taxLinesMaps.lineItemsTaxLines[item.id] ?? []
})
cartShippingMethods.forEach((method) => {
method.tax_lines = taxLinesMaps.shippingMethodsTaxLines[method.id] ?? []
})
}
const itemsTotals = await newTotalsServiceTx.getLineItemTotals(cartItems, {
includeTax,
calculationContext,
})
const shippingTotals = await newTotalsServiceTx.getShippingMethodTotals(
cartShippingMethods,
{
discounts: cart.discounts,
includeTax,
calculationContext,
}
)
cart.subtotal = 0
cart.discount_total = 0
cart.item_tax_total = 0
cart.shipping_total = 0
cart.shipping_tax_total = 0
cart.items = (cart.items || []).map((item) => {
const itemWithTotals = Object.assign(item, itemsTotals[item.id] ?? {})
cart.subtotal! += itemWithTotals.subtotal ?? 0
cart.discount_total! += itemWithTotals.discount_total ?? 0
cart.item_tax_total! += itemWithTotals.tax_total ?? 0
return itemWithTotals
})
cart.shipping_methods = (cart.shipping_methods || []).map(
(shippingMethod) => {
const methodWithTotals = Object.assign(
shippingMethod,
shippingTotals[shippingMethod.id] ?? {}
)
cart.shipping_total! += methodWithTotals.subtotal ?? 0
cart.shipping_tax_total! += methodWithTotals.tax_total ?? 0
return methodWithTotals
}
)
const giftCardTotal = await this.newTotalsService_.getGiftCardTotals(
cart.subtotal - cart.discount_total,
{
region: cart.region,
giftCards: cart.gift_cards,
}
)
cart.gift_card_total = giftCardTotal.total || 0
cart.gift_card_tax_total = giftCardTotal.tax_total || 0
cart.tax_total = cart.item_tax_total + cart.shipping_tax_total
cart.total =
cart.subtotal +
cart.shipping_total +
cart.tax_total -
(cart.gift_card_total + cart.discount_total + cart.gift_card_tax_total)
return cart as Cart & { total: number }
}
protected async refreshAdjustments_(cart: Cart): Promise<void> {
const nonReturnLineIDs = cart.items
.filter((item) => !item.is_return)
.map((i) => i.id)
const lineItemAdjustmentServiceTx =
this.lineItemAdjustmentService_.withTransaction(this.activeManager_)
// delete all old non return line item adjustments
await lineItemAdjustmentServiceTx.delete({
item_id: nonReturnLineIDs,
discount_id: Not(IsNull()),
})
// potentially create/update line item adjustments
await lineItemAdjustmentServiceTx.createAdjustments(cart)
}
protected transformQueryForTotals_(
config: FindConfig<Cart>
): FindConfig<Cart> & { totalsToSelect: TotalField[] } {
let { select, relations } = config
if (!select) {
return {
select,
relations,
totalsToSelect: [],
}
}
const totalFields = [
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"gift_card_total",
"total",
]
const totalsToSelect = select.filter((v) =>
totalFields.includes(v)
) as TotalField[]
if (totalsToSelect.length > 0) {
const relationSet = new Set(relations)
relationSet.add("items")
relationSet.add("items.tax_lines")
relationSet.add("gift_cards")
relationSet.add("discounts")
relationSet.add("discounts.rule")
// relationSet.add("discounts.parent_discount")
// relationSet.add("discounts.parent_discount.rule")
// relationSet.add("discounts.parent_discount.regions")
relationSet.add("shipping_methods")
relationSet.add("shipping_address")
relationSet.add("region")
relationSet.add("region.tax_rates")
relations = Array.from(relationSet.values())
select = select.filter((v) => !totalFields.includes(v))
}
return {
relations,
select,
totalsToSelect,
}
}
/**
* @deprecated Use decorateTotals instead
* @param cart
* @param totalsToSelect
* @param options
* @protected
*/
protected async decorateTotals_(
cart: Cart,
totalsToSelect: TotalField[],
options: TotalsConfig = { force_taxes: false }
): Promise<Cart> {
const totals: { [K in TotalField]?: number | null } = {}
for (const key of totalsToSelect) {
switch (key) {
case "total": {
totals.total = await this.totalsService_.getTotal(cart, {
force_taxes: options.force_taxes,
})
break
}
case "shipping_total": {
totals.shipping_total = await this.totalsService_.getShippingTotal(
cart
)
break
}
case "discount_total":
totals.discount_total = await this.totalsService_.getDiscountTotal(
cart
)
break
case "tax_total":
totals.tax_total = await this.totalsService_.getTaxTotal(
cart,
options.force_taxes
)
break
case "gift_card_total": {
const giftCardBreakdown = await this.totalsService_.getGiftCardTotal(
cart
)
totals.gift_card_total = giftCardBreakdown.total
totals.gift_card_tax_total = giftCardBreakdown.tax_total
break
}
case "subtotal":
totals.subtotal = await this.totalsService_.getSubtotal(cart)
break
default:
break
}
}
return Object.assign(cart, totals)
}
private getTotalsRelations(config: FindConfig<Cart>): string[] {
const relationSet = new Set(config.relations)
relationSet.add("items")
relationSet.add("items.variant")
relationSet.add("items.variant.product")
relationSet.add("items.tax_lines")
relationSet.add("items.adjustments")
relationSet.add("gift_cards")
relationSet.add("discounts")
relationSet.add("discounts.rule")
relationSet.add("shipping_methods")
relationSet.add("shipping_methods.tax_lines")
relationSet.add("shipping_address")
relationSet.add("region")
relationSet.add("region.tax_rates")
return Array.from(relationSet.values())
}
}
export default CartService