Files
medusa-store/packages/medusa/src/services/cart.ts
Sebastian Rindom 39f2c0c15e fix(medusa): calculates correct taxes and totals on order with gift cards (#1807)
**What**
Since the release of the Tax API the line item totals calculations on orders with gift cards have been wrong. To understand the bug consider the below order:

Region:
- tax_rate: 25%
- gift_cards_taxable: true

Order:
- applied gift card: 1000
- items: 
  - A: unit_price: 1000
  - B: unit_price: 500
- Subtotal: 1500

**Previous calculation method**
1. Determine how much of the gift card is used for each item using `item_total / subtotal * gift_card_amount`:
  - Item A: 1000/1500 * 1000 = 666.67
  - Item B: 500/1500 * 1000 = 333.33
2. Calculate line item totals including taxes using `(unit_price - gift_card) * (1 + tax_rate)`
  - Item A: 1000 - 666.67 = 333.33; vat amount -> 83.33
  - Item B: 500 - 333.33 = 166.67; vat amount -> 41.67
3. Add up the line item totals: order subtotal = 500; vat amount = 125; total = 625

This is all correct at the totals level; but at the line item level we should still use the "original prices" i.e. the line item total for item a should be (1000 * 1.25) = 1250 with a tax amount of 250. 

**New calculation method**
1. Use default totals calculations
  - Item A: subtotal: 1000, tax_total: 250, total: 1250
  - Item B: subtotal: 500, tax_total: 125, total: 625
2. Add up the line item totals: subtotal: 1500, tax_total: 375, total: 1875
3. Reduce total with gift card: subtotal: 1500 - 1000 = 500, tax_total: 375 - 250 = 125, total = 625

Totals can now be forwarded correctly to accounting plugins.

Fixes CORE-310.
2022-07-11 12:18:43 +00:00

2035 lines
63 KiB
TypeScript

import { isEmpty, isEqual } from "lodash"
import { MedusaError, Validator } from "medusa-core-utils"
import { DeepPartial, EntityManager, In } from "typeorm"
import { TransactionBaseService } from "../interfaces"
import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy"
import { DiscountRuleType } from "../models"
import { Address } from "../models/address"
import { Cart } from "../models/cart"
import { CustomShippingOption } from "../models/custom-shipping-option"
import { Customer } from "../models/customer"
import { Discount } from "../models/discount"
import { LineItem } from "../models/line-item"
import { ShippingMethod } from "../models/shipping-method"
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,
LineItemUpdate,
} from "../types/cart"
import { AddressPayload, FindConfig, TotalField } from "../types/common"
import { buildQuery, setMetadata, validateId } from "../utils"
import CustomShippingOptionService from "./custom-shipping-option"
import CustomerService from "./customer"
import DiscountService from "./discount"
import EventBusService from "./event-bus"
import GiftCardService from "./gift-card"
import InventoryService from "./inventory"
import LineItemService from "./line-item"
import LineItemAdjustmentService from "./line-item-adjustment"
import PaymentProviderService from "./payment-provider"
import ProductService from "./product"
import ProductVariantService from "./product-variant"
import RegionService from "./region"
import ShippingOptionService from "./shipping-option"
import TaxProviderService from "./tax-provider"
import TotalsService from "./totals"
type InjectedDependencies = {
manager: EntityManager
cartRepository: typeof CartRepository
shippingMethodRepository: typeof ShippingMethodRepository
addressRepository: typeof AddressRepository
paymentSessionRepository: typeof PaymentSessionRepository
lineItemRepository: typeof LineItemRepository
eventBusService: EventBusService
taxProviderService: TaxProviderService
paymentProviderService: PaymentProviderService
productService: ProductService
productVariantService: ProductVariantService
regionService: RegionService
lineItemService: LineItemService
shippingOptionService: ShippingOptionService
customerService: CustomerService
discountService: DiscountService
giftCardService: GiftCardService
totalsService: TotalsService
inventoryService: InventoryService
customShippingOptionService: CustomShippingOptionService
lineItemAdjustmentService: LineItemAdjustmentService
priceSelectionStrategy: IPriceSelectionStrategy
}
type TotalsConfig = {
force_taxes?: boolean
}
/* Provides layer to manipulate carts.
* @implements BaseService
*/
class CartService extends TransactionBaseService<CartService> {
static readonly Events = {
CUSTOMER_UPDATED: "cart.customer_updated",
CREATED: "cart.created",
UPDATED: "cart.updated",
}
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
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 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 inventoryService_: InventoryService
protected readonly customShippingOptionService_: CustomShippingOptionService
protected readonly priceSelectionStrategy_: IPriceSelectionStrategy
protected readonly lineItemAdjustmentService_: LineItemAdjustmentService
constructor({
manager,
cartRepository,
shippingMethodRepository,
lineItemRepository,
eventBusService,
paymentProviderService,
productService,
productVariantService,
taxProviderService,
regionService,
lineItemService,
shippingOptionService,
customerService,
discountService,
giftCardService,
totalsService,
addressRepository,
paymentSessionRepository,
inventoryService,
customShippingOptionService,
lineItemAdjustmentService,
priceSelectionStrategy,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.manager_ = manager
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.addressRepository_ = addressRepository
this.paymentSessionRepository_ = paymentSessionRepository
this.inventoryService_ = inventoryService
this.customShippingOptionService_ = customShippingOptionService
this.taxProviderService_ = taxProviderService
this.lineItemAdjustmentService_ = lineItemAdjustmentService
this.priceSelectionStrategy_ = priceSelectionStrategy
}
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,
}
}
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 = this.totalsService_.getShippingTotal(cart)
break
}
case "discount_total":
totals.discount_total = 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 = this.totalsService_.getGiftCardTotal(cart)
totals.gift_card_total = giftCardBreakdown.total
totals.gift_card_tax_total = giftCardBreakdown.tax_total
break
}
case "subtotal":
totals.subtotal = this.totalsService_.getSubtotal(cart)
break
default:
break
}
}
return Object.assign(cart, totals)
}
/**
* @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[]> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepo = transactionManager.getCustomRepository(
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 - configuration for retrieval of totals
* @return the cart document.
*/
async retrieve(
cartId: string,
options: FindConfig<Cart> = {},
totalsConfig: TotalsConfig = {}
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepo = transactionManager.getCustomRepository(
this.cartRepository_
)
const validatedId = validateId(cartId)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(options)
const query = buildQuery(
{ id: validatedId },
{ ...options, select, relations }
)
if (relations && relations.length > 0) {
query.relations = relations
}
if (select && select.length > 0) {
query.select = select
} else {
query.select = undefined
}
const queryRelations = query.relations
query.relations = undefined
const raw = await cartRepo.findOneWithRelations(queryRelations, query)
if (!raw) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Cart with ${cartId} was not found`
)
}
return await this.decorateTotals_(raw, totalsToSelect, 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> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepo = transactionManager.getCustomRepository(
this.cartRepository_
)
const addressRepo = transactionManager.getCustomRepository(
this.addressRepository_
)
const { region_id } = data
if (!region_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`A region_id must be provided when creating a cart`
)
}
const rawCart: DeepPartial<Cart> = {}
if (data.email) {
const customer = await this.createOrFetchUserFromEmail_(data.email)
rawCart.customer = customer
rawCart.customer_id = customer.id
rawCart.email = customer.email
}
const region = await this.regionService_
.withTransaction(transactionManager)
.retrieve(region_id, {
relations: ["countries"],
})
const regCountries = region.countries.map(({ iso_2 }) => iso_2)
rawCart.region_id = region.id
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(data.shipping_address_id)
if (addr && !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
}
}
const remainingFields: (keyof Cart)[] = [
"billing_address_id",
"context",
"type",
"metadata",
"discounts",
"gift_cards",
]
for (const remainingField of remainingFields) {
if (
typeof data[remainingField] !== "undefined" &&
remainingField !== "object"
) {
/* TODO: See how to fix the error TS2590 properly while keeping the DeepPartial type */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
rawCart[remainingField] = 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
}
)
}
/**
* 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.getCustomRepository(
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", "discounts", "discounts.rule"],
})
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 wheter 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
}
/**
* 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.
* @return the result of the update operation
*/
async addLineItem(cartId: string, lineItem: LineItem): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: [
"shipping_methods",
"items",
"items.adjustments",
"payment_sessions",
"items.variant",
"items.variant.product",
"discounts",
"discounts.rule",
],
})
let currentItem: LineItem | undefined
if (lineItem.should_merge) {
currentItem = cart.items.find((item) => {
if (item.should_merge && item.variant_id === lineItem.variant_id) {
return isEqual(item.metadata, lineItem.metadata)
}
return false
})
}
// 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
await this.inventoryService_
.withTransaction(transactionManager)
.confirmInventory(lineItem.variant_id, quantity)
if (currentItem) {
await this.lineItemService_
.withTransaction(transactionManager)
.update(currentItem.id, {
quantity: currentItem.quantity,
})
} else {
await this.lineItemService_
.withTransaction(transactionManager)
.create({
...lineItem,
has_shipping: false,
cart_id: cartId,
})
}
const lineItemRepository = transactionManager.getCustomRepository(
this.lineItemRepository_
)
await lineItemRepository.update(
{
id: In(cart.items.map((item) => item.id)),
},
{
has_shipping: false,
}
)
const result = await this.retrieve(cartId, {
relations: ["items", "discounts", "discounts.rule"],
})
await this.refreshAdjustments_(result)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, result)
return result
}
)
}
/**
* 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 cart = await this.retrieve(cartId, {
relations: ["items", "items.adjustments", "payment_sessions"],
})
// Ensure that the line item exists in the cart
const lineItemExists = cart.items.find((i) => i.id === lineItemId)
if (!lineItemExists) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"A line item with the provided id doesn't exist in the cart"
)
}
if (lineItemUpdate.quantity) {
const hasInventory = await this.inventoryService_
.withTransaction(transactionManager)
.confirmInventory(
lineItemExists.variant_id,
lineItemUpdate.quantity
)
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", "discounts", "discounts.rule"],
})
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> {
const transactionManager = this.transactionManager_ ?? this.manager_
if (cart.shipping_methods?.length) {
const shippingMethodRepository = transactionManager.getCustomRepository(
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.getCustomRepository(
this.cartRepository_
)
const cart = await this.retrieve(cartId, {
select: [
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"total",
],
relations: [
"items",
"shipping_methods",
"shipping_address",
"billing_address",
"gift_cards",
"customer",
"region",
"payment_sessions",
"region.countries",
"discounts",
"discounts.rule",
"discounts.regions",
],
})
if (data.customer_id) {
await this.updateCustomerId_(cart, data.customer_id)
} else {
if (typeof data.email !== "undefined") {
const customer = await this.createOrFetchUserFromEmail_(data.email)
cart.customer = customer
cart.customer_id = customer.id
cart.email = customer.email
}
}
if (
typeof data.customer_id !== "undefined" ||
typeof data.region_id !== "undefined"
) {
await this.updateUnitPrices_(cart, data.region_id, data.customer_id)
}
if (typeof data.region_id !== "undefined") {
const countryCode =
(data.country_code || data.shipping_address?.country_code) ?? null
await this.setRegion_(cart, data.region_id, countryCode)
}
const addrRepo = transactionManager.getCustomRepository(
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 (typeof data.discounts !== "undefined") {
const previousDiscounts = [...cart.discounts]
cart.discounts.length = 0
await Promise.all(
data.discounts.map(({ code }) => {
return this.applyDiscount(cart, code)
})
)
const hasFreeShipping = cart.discounts.some(
({ rule }) => rule?.type === "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 === "free_shipping"
) &&
!hasFreeShipping
) {
await this.adjustFreeShipping_(cart, false)
}
if (hasFreeShipping) {
await this.adjustFreeShipping_(cart, true)
}
}
if ("gift_cards" in data) {
cart.gift_cards = []
await Promise.all(
(data.gift_cards ?? []).map(({ 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 ("email" in data || "customer_id" in data) {
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.CUSTOMER_UPDATED, updatedCart.id)
}
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* 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.transactionManager_)
.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 createOrFetchUserFromEmail_(
email: string
): Promise<Customer> {
const schema = Validator.string().email().required()
const { value, error } = schema.validate(email.toLowerCase())
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The email is not valid"
)
}
let customer = await this.customerService_
.withTransaction(this.transactionManager_)
.retrieveByEmail(value)
.catch(() => undefined)
if (!customer) {
customer = await this.customerService_
.withTransaction(this.transactionManager_)
.create({ email: value })
}
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: AddressRepository
): Promise<void> {
let address: Address
if (typeof addressOrId === `string`) {
address = (await addrRepo.findOne({
where: { id: addressOrId },
})) as Address
} else {
address = addressOrId as Address
}
address.country_code = address.country_code?.toLowerCase() ?? null
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: 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
}
address.country_code = address.country_code?.toLowerCase() ?? null
if (
address.country_code &&
!cart.region.countries.find(({ iso_2 }) => address.country_code === 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.transactionManager_)
.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
* @return the result of the update operation
*/
async applyDiscount(cart: Cart, discountCode: string): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const discount = await this.discountService_
.withTransaction(transactionManager)
.retrieveByCode(discountCode, { relations: ["rule", "regions"] })
await this.discountService_
.withTransaction(transactionManager)
.validateDiscountForCartOrThrow(cart, discount)
const rule = discount.rule
// if discount is already there, we simply resolve
if (cart.discounts.find(({ id }) => id === discount.id)) {
return
}
const toParse = [...cart.discounts, discount]
let sawNotShipping = false
const newDiscounts = toParse.map((discountToParse) => {
switch (discountToParse.rule?.type) {
case DiscountRuleType.FREE_SHIPPING:
if (discountToParse.rule.type === rule.type) {
return discount
}
return discountToParse
default:
if (!sawNotShipping) {
sawNotShipping = true
if (rule?.type !== "free_shipping") {
return discount
}
return discountToParse
}
return null
}
})
cart.discounts = newDiscounts.filter(
(newDiscount): newDiscount is Discount => {
return !!newDiscount
}
)
// ignore if free shipping
if (rule?.type !== "free_shipping" && 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: [
"discounts",
"discounts.rule",
"payment_sessions",
"shipping_methods",
],
})
if (cart.discounts.some(({ rule }) => rule.type === "free_shipping")) {
await this.adjustFreeShipping_(cart, false)
}
cart.discounts = cart.discounts.filter(
(discount) => discount.code !== discountCode
)
const cartRepo = transactionManager.getCustomRepository(
this.cartRepository_
)
const updatedCart = await cartRepo.save(cart)
if (updatedCart.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: object): 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> = {}
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepository = transactionManager.getCustomRepository(
this.cartRepository_
)
const cart = await this.retrieve(cartId, {
select: ["total"],
relations: [
"items",
"items.adjustments",
"region",
"payment_sessions",
],
})
if (typeof cart.total === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"cart.total should be defined"
)
}
// If cart total is 0, we don't perform anything payment related
if (cart.total <= 0) {
cart.payment_authorized_at = new Date()
return cartRepository.save(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)
const freshCart = await this.retrieve(cart.id, {
select: ["total"],
relations: ["payment_sessions", "items", "items.adjustments"],
})
if (session.status === "authorized") {
freshCart.payment = await this.paymentProviderService_
.withTransaction(transactionManager)
.createPayment(freshCart)
freshCart.payment_authorized_at = new Date()
}
const updatedCart = await cartRepository.save(freshCart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* Sets a payment method for a cart.
* @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
* @return result of update operation
*/
async setPaymentSession(cartId: string, providerId: string): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const psRepo = transactionManager.getCustomRepository(
this.paymentSessionRepository_
)
const cart = await this.retrieve(cartId, {
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"gift_card_total",
],
relations: ["region", "region.payment_providers", "payment_sessions"],
})
// The region must have the provider id in its providers array
if (
providerId !== "system" &&
!(
cart.region.payment_providers.length &&
cart.region.payment_providers.find(({ id }) => providerId === id)
)
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`The payment method is not available in this region`
)
}
await Promise.all(
cart.payment_sessions.map(async (paymentSession) => {
return psRepo.save({ ...paymentSession, is_selected: null })
})
)
const paymentSession = cart.payment_sessions.find(
(ps) => ps.provider_id === providerId
)
if (!paymentSession) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Could not find payment session"
)
}
paymentSession.is_selected = true
await psRepo.save(paymentSession)
const updatedCart = await this.retrieve(cartId)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
},
"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.getCustomRepository(
this.paymentSessionRepository_
)
const cartId =
typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id
const cart = await this.retrieve(
cartId,
{
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"shipping_total",
"gift_card_total",
],
relations: [
"items",
"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
if (typeof total === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"cart.total must be defined"
)
}
// If there are existing payment sessions ensure that these are up to date
const seen: string[] = []
if (cart.payment_sessions?.length) {
await Promise.all(
cart.payment_sessions.map(async (paymentSession) => {
if (
total <= 0 ||
!region.payment_providers.find(
({ id }) => id === paymentSession.provider_id
)
) {
return this.paymentProviderService_
.withTransaction(transactionManager)
.deleteSession(paymentSession)
} else {
seen.push(paymentSession.provider_id)
return this.paymentProviderService_
.withTransaction(transactionManager)
.updateSession(paymentSession, cart)
}
})
)
}
if (total > 0) {
// If only one payment session exists, we preselect it
if (region.payment_providers.length === 1 && !cart.payment_session) {
const paymentProvider = region.payment_providers[0]
const paymentSession = await this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(paymentProvider.id, cart)
paymentSession.is_selected = true
await psRepo.save(paymentSession)
} else {
await Promise.all(
region.payment_providers.map(async (paymentProvider) => {
if (!seen.includes(paymentProvider.id)) {
return this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(paymentProvider.id, cart)
}
return
})
)
}
}
}
)
}
/**
* 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<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: ["payment_sessions"],
})
const cartRepo = transactionManager.getCustomRepository(
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
)
if (paymentSession) {
// Delete the session with the provider
await this.paymentProviderService_
.withTransaction(transactionManager)
.deleteSession(paymentSession)
}
}
await cartRepo.save(cart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, cart)
return cart
}
)
}
/**
* 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<Cart>} the resulting cart.
*/
async refreshPaymentSession(
cartId: string,
providerId: string
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: ["payment_sessions"],
})
if (cart.payment_sessions) {
const paymentSession = cart.payment_sessions.find(
({ provider_id }) => provider_id === providerId
)
if (paymentSession) {
// Delete the session with the provider
await this.paymentProviderService_
.withTransaction(transactionManager)
.refreshSession(paymentSession, cart)
}
}
const updatedCart = await this.retrieve(cartId)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
/**
* 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.retrieve(cartId, {
select: ["subtotal"],
relations: [
"shipping_methods",
"discounts",
"discounts.rule",
"shipping_methods.shipping_option",
"items",
"items.variant",
"payment_sessions",
"items.variant.product",
],
})
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) {
for (const shippingMethod of shipping_methods) {
if (
shippingMethod.shipping_option.profile_id ===
newShippingMethod.shipping_option.profile_id
) {
await this.shippingOptionService_
.withTransaction(transactionManager)
.deleteShippingMethods(shippingMethod)
} else {
methods.push(shippingMethod)
}
}
}
if (cart.items?.length) {
await Promise.all(
cart.items.map(async (item) => {
return this.lineItemService_
.withTransaction(transactionManager)
.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 === "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> {
const transactionManager = this.transactionManager_ ?? this.manager_
// 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.transactionManager_)
.retrieve(regionId || cart.region_id, {
relations: ["countries"],
})
cart.items = (
await Promise.all(
cart.items.map(async (item) => {
const availablePrice = await this.priceSelectionStrategy_
.withTransaction(transactionManager)
.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
) {
return this.lineItemService_
.withTransaction(transactionManager)
.update(item.id, {
has_shipping: false,
unit_price: availablePrice.calculatedPrice,
})
} else {
await this.lineItemService_
.withTransaction(transactionManager)
.delete(item.id)
return
}
})
)
).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> {
const transactionManager = this.transactionManager_ ?? this.manager_
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(transactionManager)
.retrieve(regionId, {
relations: ["countries"],
})
cart.region = region
cart.region_id = region.id
const addrRepo = transactionManager.getCustomRepository(
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 = {
...shippingAddress,
country_code: countryCode.toLowerCase(),
}
await addrRepo.save(updated)
} 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(transactionManager)
.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 = transactionManager.getCustomRepository(
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",
"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.getCustomRepository(
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.getCustomRepository(
this.cartRepository_
)
const validatedId = validateId(cartId)
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(validatedId)
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)
this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
async createTaxLines(id: string): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(id, {
relations: [
"customer",
"discounts",
"discounts.rule",
"gift_cards",
"items",
"items.adjustments",
"region",
"region.tax_rates",
"shipping_address",
"shipping_methods",
],
})
const calculationContext = this.totalsService_
.withTransaction(transactionManager)
.getCalculationContext(cart)
await this.taxProviderService_
.withTransaction(transactionManager)
.createTaxLines(cart, calculationContext)
return cart
}
)
}
protected async refreshAdjustments_(cart: Cart): Promise<void> {
const transactionManager = this.transactionManager_ ?? this.manager_
const nonReturnLineIDs = cart.items
.filter((item) => !item.is_return)
.map((i) => i.id)
// delete all old non return line item adjustments
await this.lineItemAdjustmentService_
.withTransaction(transactionManager)
.delete({
item_id: nonReturnLineIDs,
})
// potentially create/update line item adjustments
await this.lineItemAdjustmentService_
.withTransaction(transactionManager)
.createAdjustments(cart)
}
/**
* Dedicated method to delete metadata for a cart.
* @param cartId - the cart to delete metadata from.
* @param key - key for metadata field
* @return resolves to the updated result.
*/
async deleteMetadata(cartId: string, key: string): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepo = transactionManager.getCustomRepository(
this.cartRepository_
)
const validatedId = validateId(cartId)
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(validatedId)
if (!cart) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Cart with id: ${validatedId} was not found`
)
}
const updated = cart.metadata || {}
delete updated[key]
cart.metadata = updated
const updatedCart = await cartRepo.save(cart)
this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
}
)
}
}
export default CartService