fix(medusa): Optimize Cart totals calculation (#2372)

**What**

The existing totals calculations are extremely heavy and perform an enormous amount of duplicate work. The changes here remove large parts of the overhead and improves response times for cart endpoints up to 30x.
This commit is contained in:
Sebastian Rindom
2022-10-07 08:44:06 +00:00
committed by GitHub
parent 527c587d8f
commit 3d255302b0
45 changed files with 10970 additions and 8082 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": minor
---
Improve performance of cart total calculations
@@ -32,7 +32,7 @@ describe("/admin/orders", () => {
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false })
})
afterAll(async () => {
@@ -495,7 +495,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
const expectedItemTotals = {
subtotal: 200,
gift_card_total: 0,
discount_total: 30,
total: 204,
original_total: 240,
@@ -28,7 +28,7 @@ describe("Automatic Cart Taxes", () => {
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false })
})
afterAll(async () => {
@@ -74,7 +74,6 @@ describe("Cart Totals Calculations", () => {
expect(res.data.cart.items[0].original_total).toEqual(110)
expect(res.data.cart.items[0].original_tax_total).toEqual(10)
expect(res.data.cart.items[0].discount_total).toEqual(0)
expect(res.data.cart.items[0].gift_card_total).toEqual(0)
})
it("sets correct line item totals for a cart with item of price 100; tax rate 10; discount 10", async () => {
@@ -123,7 +122,6 @@ describe("Cart Totals Calculations", () => {
expect(res.data.cart.items[0].original_total).toEqual(110)
expect(res.data.cart.items[0].original_tax_total).toEqual(10)
expect(res.data.cart.items[0].discount_total).toEqual(10)
expect(res.data.cart.items[0].gift_card_total).toEqual(0)
})
it("doesn't include taxes in !automatic_taxes regions", async () => {
@@ -174,7 +172,6 @@ describe("Cart Totals Calculations", () => {
expect(res.data.cart.items[0].original_total).toEqual(100)
expect(res.data.cart.items[0].original_tax_total).toEqual(0)
expect(res.data.cart.items[0].discount_total).toEqual(10)
expect(res.data.cart.items[0].gift_card_total).toEqual(0)
})
it("includes taxes in !automatic_taxes regions when forced", async () => {
@@ -227,6 +224,5 @@ describe("Cart Totals Calculations", () => {
expect(res.data.cart.items[0].original_total).toEqual(110)
expect(res.data.cart.items[0].original_tax_total).toEqual(10)
expect(res.data.cart.items[0].discount_total).toEqual(10)
expect(res.data.cart.items[0].gift_card_total).toEqual(0)
})
})
@@ -25,7 +25,7 @@ describe("Manual Cart Taxes", () => {
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false })
})
afterAll(async () => {
@@ -76,7 +76,7 @@ describe("Manual Cart Taxes", () => {
const response = await api.get("/store/carts/test-cart")
expect(response.status).toEqual(200)
expect(response.data.cart.tax_total).toEqual(null)
expect(response.data.cart.tax_total).toEqual(0)
expect(response.data.cart.total).toEqual(100)
})
+4
View File
@@ -0,0 +1,4 @@
npmRegistryServer: "http://localhost:4873"
unsafeHttpWhitelist:
- localhost
@@ -328,7 +328,6 @@ Object {
"title": "Intelligent Plastic Chips",
"totals": Object {
"discount_total": 0,
"gift_card_total": 0,
"original_tax_total": 200,
"original_total": 1200,
"quantity": 1,
@@ -1022,7 +1021,6 @@ Object {
"title": "Intelligent Plastic Chips",
"totals": Object {
"discount_total": 0,
"gift_card_total": 0,
"original_tax_total": 400,
"original_total": 2400,
"quantity": 2,
@@ -1477,7 +1475,6 @@ Object {
"title": "Intelligent Plastic Chips",
"totals": Object {
"discount_total": 0,
"gift_card_total": 0,
"original_tax_total": 200,
"original_total": 1200,
"quantity": 1,
+5 -5
View File
@@ -8,18 +8,18 @@
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/medusa": "1.2.1-dev-1649009241281",
"@medusajs/medusa": "1.4.1-dev-1665082901122",
"faker": "^5.5.3",
"medusa-fulfillment-webshipper": "1.2.1-dev-1649009241281",
"medusa-interfaces": "1.2.1-dev-1649009241281",
"medusa-plugin-sendgrid": "1.2.1-dev-1649009241281",
"medusa-fulfillment-webshipper": "1.3.3-dev-1665082901122",
"medusa-interfaces": "1.3.3-dev-1665082901122",
"medusa-plugin-sendgrid": "1.3.3-dev-1665082901122",
"typeorm": "^0.2.31"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/node": "^7.12.10",
"babel-preset-medusa-package": "1.1.19-dev-1649009241281",
"babel-preset-medusa-package": "1.1.19-dev-1665082901122",
"jest": "^26.6.3"
}
}
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,8 @@ describe("POST /store/carts/:id/shipping-methods", () => {
})
it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -75,7 +76,8 @@ describe("POST /store/carts/:id/shipping-methods", () => {
})
it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -124,7 +126,8 @@ describe("POST /store/carts/:id/shipping-methods", () => {
})
it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -35,7 +35,7 @@ describe("POST /store/carts", () => {
})
it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -25,7 +25,8 @@ describe("POST /store/carts/:id", () => {
})
it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(3)
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("calls LineItemService generate", () => {
@@ -22,7 +22,7 @@ describe("POST /store/carts/:id/payment-sessions", () => {
})
it("calls Cart service retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -15,7 +15,8 @@ describe("GET /store/carts", () => {
})
it("calls retrieve from CartService", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns cart", () => {
@@ -41,7 +42,7 @@ describe("GET /store/carts", () => {
it("calls get product from productSerice", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieve).toHaveBeenCalledWith("none", {
relations: ["customer"],
select: ["id", "customer_id"],
})
})
@@ -28,7 +28,7 @@ describe("POST /store/carts/:id/payment-session/update", () => {
})
it("calls CartService retrive", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -61,14 +61,15 @@ describe("POST /store/carts/:id", () => {
})
it("calls get product from productService", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("emptyCart"),
{
relations: ["payment_sessions", "shipping_methods"],
}
)
expect(CartServiceMock.retrieve).toHaveBeenCalledWith(
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledWith(
IdMap.getId("emptyCart"),
{
relations: defaultStoreCartRelations,
@@ -39,7 +39,8 @@ describe("POST /store/carts/:id/line-items/:line_id", () => {
})
it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(3)
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -42,7 +42,7 @@ describe("POST /store/carts/:id/payment-session/update", () => {
})
it("calls CartService retrive", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -3,7 +3,6 @@ import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { EntityManager } from "typeorm"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { validator } from "../../../../utils/validator"
/**
@@ -86,13 +85,11 @@ export default async (req, res) => {
}
})
const updatedCart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(updatedCart, req)
res.status(200).json({ cart: data })
}
@@ -2,7 +2,6 @@ import { CartService, IdempotencyKeyService } from "../../../../services"
import { EntityManager } from "typeorm"
import { IdempotencyKey } from "../../../../models/idempotency-key"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/**
* @oas [post] /carts/{id}/taxes
@@ -49,11 +48,11 @@ export default async (req, res) => {
const headerKey = req.get("Idempotency-Key") || ""
let idempotencyKey
let idempotencyKey: IdempotencyKey
try {
await manager.transaction(async (transactionManager) => {
idempotencyKey = await idempotencyKeyService
idempotencyKey = await manager.transaction(async (transactionManager) => {
return await idempotencyKeyService
.withTransaction(transactionManager)
.initializeRequest(headerKey, req.method, req.params, req.path)
})
@@ -82,29 +81,11 @@ export default async (req, res) => {
async (manager: EntityManager) => {
const cart = await cartService
.withTransaction(manager)
.retrieve(
id,
{
relations: ["items", "items.adjustments"],
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"shipping_total",
"gift_card_total",
],
},
{ force_taxes: true }
)
const data = await decorateLineItemsWithTotals(cart, req, {
force_taxes: true,
})
.retrieveWithTotals(id, {}, { force_taxes: true })
return {
response_code: 200,
response_body: { cart: data },
response_body: { cart },
}
}
)
@@ -113,7 +94,7 @@ export default async (req, res) => {
inProgress = false
err = error
} else {
idempotencyKey = key
idempotencyKey = key!
}
})
break
@@ -21,7 +21,6 @@ import { Cart } from "../../../../models"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { FlagRouter } from "../../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { CartCreateProps } from "../../../../types/cart"
import { isDefined } from "../../../../utils"
@@ -181,14 +180,12 @@ export default async (req, res) => {
}
})
cart = await cartService.retrieve(cart!.id, {
cart = await cartService.retrieveWithTotals(cart!.id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
res.status(200).json({ cart })
}
export class Item {
@@ -3,7 +3,6 @@ import { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService, LineItemService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { FlagRouter } from "../../../../utils/flag-router"
/**
@@ -98,13 +97,11 @@ export default async (req, res) => {
}
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
@@ -1,6 +1,5 @@
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm"
import IdempotencyKeyService from "../../../../services/idempotency-key"
@@ -92,19 +91,14 @@ export default async (req, res) => {
const cart = await cartService
.withTransaction(stageManager)
.retrieve(id, {
.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req, {
force_taxes: false,
transactionManager: stageManager,
})
return {
response_code: 200,
response_body: { cart: data },
response_body: { cart },
}
}
)
@@ -1,48 +0,0 @@
import { Request } from "express"
import { TotalsService } from "../../../../services"
import { Cart, LineItem } from "../../../../models"
import { EntityManager } from "typeorm"
export const decorateLineItemsWithTotals = async (
cart: Cart,
req: Request,
options: { force_taxes: boolean; transactionManager?: EntityManager } = {
force_taxes: false,
}
): Promise<Cart> => {
const totalsService: TotalsService = req.scope.resolve("totalsService")
if (cart.items && cart.region) {
const getItems = async (manager) => {
const totalsServiceTx = totalsService.withTransaction(manager)
return await Promise.all(
cart.items.map(async (item: LineItem) => {
const itemTotals = await totalsServiceTx.getLineItemTotals(
item,
cart,
{
include_tax: options.force_taxes || cart.region.automatic_taxes,
}
)
return Object.assign(item, itemTotals)
})
)
}
let items
if (options.transactionManager) {
items = await getItems(options.transactionManager)
} else {
const manager: EntityManager =
options.transactionManager ?? req.scope.resolve("manager")
items = await manager.transaction(async (transactionManager) => {
return await getItems(transactionManager)
})
}
return Object.assign(cart, { items })
}
return cart
}
@@ -1,7 +1,6 @@
import { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/**
* @oas [delete] /carts/{id}/discounts/{code}
@@ -67,11 +66,10 @@ export default async (req, res) => {
}
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
@@ -1,7 +1,6 @@
import { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/**
* @oas [delete] /carts/{id}/line-items/{line_id}
@@ -67,11 +66,9 @@ export default async (req, res) => {
}
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
@@ -1,6 +1,5 @@
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm"
/**
@@ -59,11 +58,10 @@ export default async (req, res) => {
.deletePaymentSession(id, provider_id)
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
@@ -1,5 +1,4 @@
import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/**
* @oas [get] /carts/{id}
@@ -49,8 +48,8 @@ export default async (req, res) => {
const cartService: CartService = req.scope.resolve("cartService")
let cart = await cartService.retrieve(id, {
relations: ["customer"],
const cart = await cartService.retrieve(id, {
select: ["id", "customer_id"],
})
// If there is a logged in user add the user to the cart
@@ -66,8 +65,6 @@ export default async (req, res) => {
}
}
cart = await cartService.retrieve(id, req.retrieveConfig)
const data = await decorateLineItemsWithTotals(cart, req)
const data = await cartService.retrieveWithTotals(id, req.retrieveConfig)
res.json({ cart: data })
}
@@ -121,14 +121,7 @@ export default (app, container) => {
return app
}
export const defaultStoreCartFields: (keyof Cart)[] = [
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"gift_card_total",
"total",
]
export const defaultStoreCartFields: (keyof Cart)[] = []
export const defaultStoreCartRelations = [
"gift_cards",
@@ -1,5 +1,4 @@
import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm"
/**
@@ -57,14 +56,7 @@ export default async (req, res) => {
.withTransaction(transactionManager)
.refreshPaymentSession(id, provider_id)
})
const cart = await cartService.retrieve(id, {
select: [
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"total",
],
const data = await cartService.retrieveWithTotals(id, {
relations: [
"region",
"region.countries",
@@ -75,6 +67,5 @@ export default async (req, res) => {
],
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
@@ -3,7 +3,6 @@ import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { EntityManager } from "typeorm"
import { IsString } from "class-validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { validator } from "../../../../utils/validator"
/**
@@ -73,12 +72,11 @@ export default async (req, res) => {
.setPaymentSession(id, validated.provider_id)
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
@@ -14,7 +14,6 @@ import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators
import { IsType } from "../../../../utils/validators/is-type"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { Type } from "class-transformer"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/**
* @oas [post] /carts/{id}
@@ -152,12 +151,10 @@ export default async (req, res) => {
}
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.json({ cart: data })
}
@@ -4,7 +4,6 @@ import { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/**
* @oas [post] /carts/{id}/line-items/{line_id}
@@ -107,11 +106,10 @@ export default async (req, res) => {
}
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
@@ -2,7 +2,6 @@ import { IsObject } from "class-validator"
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm"
/**
@@ -78,11 +77,10 @@ export default async (req, res) => {
.updatePaymentSession(id, validated.data)
})
const cart = await cartService.retrieve(id, {
const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data })
}
+2
View File
@@ -333,6 +333,8 @@ export class Cart extends SoftDeletableEntity {
shipping_total?: number
discount_total?: number
item_tax_total?: number | null
shipping_tax_total?: number | null
tax_total?: number | null
refunded_total?: number
total?: number
@@ -94,6 +94,10 @@ export class ShippingMethod {
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax: boolean
subtotal?: number
total?: number
tax_total?: number
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "sm")
+37 -5
View File
@@ -39,10 +39,12 @@ export const carts = {
},
total: 1000,
region_id: IdMap.getId("testRegion"),
shipping_options: [{
id: IdMap.getId("tax-inclusive-option"),
includes_tax: true
}],
shipping_options: [
{
id: IdMap.getId("tax-inclusive-option"),
includes_tax: true,
},
],
},
testSwapCart: {
id: IdMap.getId("test-swap"),
@@ -223,7 +225,7 @@ export const carts = {
}
export const CartServiceMock = {
withTransaction: function() {
withTransaction: function () {
return this
},
updatePaymentSession: jest.fn().mockImplementation((data) => {
@@ -254,6 +256,36 @@ export const CartServiceMock = {
}
return Promise.resolve(carts.regionCart)
}),
retrieveWithTotals: jest.fn().mockImplementation((cartId) => {
if (cartId === IdMap.getId("fr-cart")) {
return Promise.resolve(carts.frCart)
}
if (cartId === IdMap.getId("swap-cart")) {
return Promise.resolve(carts.testSwapCart)
}
if (cartId === IdMap.getId("test-cart")) {
return Promise.resolve(carts.testCart)
}
if (cartId === IdMap.getId("cartLineItemMetadata")) {
return Promise.resolve(carts.cartWithMetadataLineItem)
}
if (cartId === IdMap.getId("regionCart")) {
return Promise.resolve(carts.regionCart)
}
if (cartId === IdMap.getId("emptyCart")) {
return Promise.resolve(carts.emptyCart)
}
if (cartId === IdMap.getId("cartWithPaySessions")) {
return Promise.resolve(carts.cartWithPaySessions)
}
if (cartId === IdMap.getId("test-cart2")) {
return Promise.resolve(carts.testCart)
}
if (cartId === IdMap.getId("tax-inclusive-option")) {
return Promise.resolve(carts.testCartTaxInclusive)
}
throw new MedusaError(MedusaError.Types.NOT_FOUND, "cart not found")
}),
retrieve: jest.fn().mockImplementation((cartId) => {
if (cartId === IdMap.getId("fr-cart")) {
return Promise.resolve(carts.frCart)
+15 -1
View File
@@ -18,6 +18,13 @@ describe("CartService", () => {
withTransaction: function () {
return this
},
getShippingMethodTotals: (m) => {
return m
},
getLineItemTotals: (i) => {
return i
},
getCalculationContext: () => {},
getTotal: (o) => {
return o.total || 0
},
@@ -849,10 +856,12 @@ describe("CartService", () => {
}
return Promise.resolve({
id: IdMap.getId("cartWithLine"),
total: 100,
items: [
{
id: IdMap.getId("existing"),
variant_id: IdMap.getId("good"),
subtotal: 100,
quantity: 1,
},
],
@@ -1370,6 +1379,7 @@ describe("CartService", () => {
describe("setPaymentSessions", () => {
const cart1 = {
total: 100,
items: [{ subtotal: 100 }],
payment_sessions: [],
region: {
payment_providers: [{ id: "provider_1" }, { id: "provider_2" }],
@@ -1386,6 +1396,8 @@ describe("CartService", () => {
const cart3 = {
total: 100,
items: [{ subtotal: 100 }],
shipping_methods: [{ subtotal: 100 }],
payment_sessions: [
{ provider_id: "provider_1" },
{ provider_id: "not_in_region" },
@@ -1397,6 +1409,8 @@ describe("CartService", () => {
const cart4 = {
total: 0,
items: [{ total: 0 }],
shipping_methods: [],
payment_sessions: [
{ provider_id: "provider_1" },
{ provider_id: "provider_2" },
@@ -1577,7 +1591,7 @@ describe("CartService", () => {
shipping_methods: [{ id: "ship1", profile: "profile1" }],
})
const cart3 = buildCart("lines", {
items: [{ id: "line", profile: "profile1" }],
items: [{ id: "line", profile: "profile1", subtotal: 100 }],
})
const cartWithCustomSO = buildCart("cart-with-custom-so")
+28 -29
View File
@@ -5,7 +5,7 @@ import { LineItemServiceMock } from "../__mocks__/line-item"
describe("OrderService", () => {
const totalsService = {
withTransaction: function() {
withTransaction: function () {
return this
},
getLineItemRefund: () => {},
@@ -40,7 +40,7 @@ describe("OrderService", () => {
const eventBusService = {
emit: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -56,20 +56,20 @@ describe("OrderService", () => {
})
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
const shippingOptionService = {
updateShippingMethod: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
const giftCardService = {
update: jest.fn(),
createTransaction: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -81,7 +81,7 @@ describe("OrderService", () => {
cancelPayment: jest.fn().mockImplementation((payment) => {
return Promise.resolve({ ...payment, status: "cancelled" })
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -91,7 +91,7 @@ describe("OrderService", () => {
total: 0,
}
const cartService = {
retrieve: jest.fn().mockImplementation((query) => {
retrieveWithTotals: jest.fn().mockImplementation((query) => {
if (query === "empty") {
return Promise.resolve(emptyCart)
}
@@ -120,7 +120,7 @@ describe("OrderService", () => {
total: 100,
})
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -180,7 +180,9 @@ describe("OrderService", () => {
total: 100,
}
orderService.cartService_.retrieve = jest.fn(() => Promise.resolve(cart))
orderService.cartService_.retrieveWithTotals = jest.fn(() =>
Promise.resolve(cart)
)
orderService.cartService_.update = jest.fn(() => Promise.resolve())
await orderService.createFromCart("cart_id")
@@ -199,9 +201,8 @@ describe("OrderService", () => {
metadata: {},
}
expect(cartService.retrieve).toHaveBeenCalledTimes(1)
expect(cartService.retrieve).toHaveBeenCalledWith("cart_id", {
select: ["subtotal", "total"],
expect(cartService.retrieveWithTotals).toHaveBeenCalledTimes(1)
expect(cartService.retrieveWithTotals).toHaveBeenCalledWith("cart_id", {
relations: [
"region",
"payment",
@@ -283,7 +284,7 @@ describe("OrderService", () => {
total: 100,
}
orderService.cartService_.retrieve = () => {
orderService.cartService_.retrieveWithTotals = () => {
return Promise.resolve(cart)
}
orderService.cartService_.update = () => Promise.resolve()
@@ -374,7 +375,7 @@ describe("OrderService", () => {
],
total: 0,
}
orderService.cartService_.retrieve = () => Promise.resolve(cart)
orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart)
await orderService.createFromCart(cart)
const order = {
payment_status: "awaiting",
@@ -432,7 +433,7 @@ describe("OrderService", () => {
],
total: 100,
}
orderService.cartService_.retrieve = () => Promise.resolve(cart)
orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart)
orderService.cartService_.update = () => Promise.resolve()
const res = orderService.createFromCart(cart)
await expect(res).rejects.toThrow(
@@ -617,14 +618,14 @@ describe("OrderService", () => {
const fulfillmentService = {
cancelFulfillment: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
const paymentProviderService = {
cancelPayment: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -721,7 +722,7 @@ describe("OrderService", () => {
? Promise.reject()
: Promise.resolve({ ...p, captured_at: "notnull" })
),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -826,7 +827,7 @@ describe("OrderService", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -839,7 +840,7 @@ describe("OrderService", () => {
},
])
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1006,7 +1007,7 @@ describe("OrderService", () => {
})
}
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1075,7 +1076,7 @@ describe("OrderService", () => {
.mockImplementation((p) =>
p.id === "payment_fail" ? Promise.reject() : Promise.resolve()
),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1216,7 +1217,7 @@ describe("OrderService", () => {
.fn()
.mockImplementation(() => Promise.resolve({})),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1350,7 +1351,7 @@ describe("OrderService", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1373,7 +1374,7 @@ describe("OrderService", () => {
],
})
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1400,9 +1401,7 @@ describe("OrderService", () => {
)
expect(fulfillmentService.createShipment).toHaveBeenCalledTimes(1)
expect(
fulfillmentService.createShipment
).toHaveBeenCalledWith(
expect(fulfillmentService.createShipment).toHaveBeenCalledWith(
IdMap.getId("fulfillment"),
[{ tracking_number: "1234" }, { tracking_number: "2345" }],
{ metadata: undefined, no_notification: true }
@@ -1494,7 +1493,7 @@ describe("OrderService", () => {
refundPayment: jest
.fn()
.mockImplementation((p) => Promise.resolve({ id: "ref" })),
withTransaction: function() {
withTransaction: function () {
return this
},
}
+136 -27
View File
@@ -26,7 +26,7 @@ import {
LineItemUpdate,
} from "../types/cart"
import { AddressPayload, FindConfig, TotalField } from "../types/common"
import { buildQuery, isDefined, setMetadata, validateId } from "../utils"
import { buildQuery, isDefined, setMetadata } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import { validateEmail } from "../utils/is-email"
import CustomShippingOptionService from "./custom-shipping-option"
@@ -175,6 +175,24 @@ class CartService extends TransactionBaseService {
this.storeService_ = storeService
}
private getTotalsRelations(config: FindConfig<Cart>): string[] {
const relationSet = new Set(config.relations)
relationSet.add("items")
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())
}
protected transformQueryForTotals_(
config: FindConfig<Cart>
): FindConfig<Cart> & { totalsToSelect: TotalField[] } {
@@ -297,7 +315,6 @@ class CartService extends TransactionBaseService {
* 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(
@@ -307,15 +324,11 @@ class CartService extends TransactionBaseService {
): Promise<Cart> {
const manager = this.manager_
const cartRepo = manager.getCustomRepository(this.cartRepository_)
const validatedId = validateId(cartId)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(options)
const query = buildQuery(
{ id: validatedId },
{ ...options, select, relations }
)
const query = buildQuery({ id: cartId }, { ...options, select, relations })
if (relations && relations.length > 0) {
query.relations = relations
@@ -327,6 +340,32 @@ class CartService extends TransactionBaseService {
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)
}
private async retrieveNew(
cartId: string,
options: FindConfig<Cart> = {}
): Promise<Cart> {
const manager = this.manager_
const cartRepo = manager.getCustomRepository(this.cartRepository_)
const query = buildQuery({ id: cartId }, options)
if ((options.select || []).length <= 0) {
query.select = undefined
}
const queryRelations = query.relations
query.relations = undefined
@@ -339,7 +378,22 @@ class CartService extends TransactionBaseService {
)
}
return await this.decorateTotals_(raw, totalsToSelect, totalsConfig)
return raw
}
async retrieveWithTotals(
cartId: string,
options: FindConfig<Cart> = {},
totalsConfig: TotalsConfig = {}
): Promise<Cart> {
const relations = this.getTotalsRelations(options)
const cart = await this.retrieveNew(cartId, {
...options,
relations,
})
return await this.decorateTotals(cart, totalsConfig)
}
/**
@@ -1427,14 +1481,7 @@ class CartService extends TransactionBaseService {
this.paymentSessionRepository_
)
const cart = await this.retrieve(cartId, {
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"gift_card_total",
],
const cart = await this.retrieveWithTotals(cartId, {
relations: ["region", "region.payment_providers", "payment_sessions"],
})
@@ -1503,17 +1550,9 @@ class CartService extends TransactionBaseService {
const cartId =
typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id
const cart = await this.retrieve(
const cart = await this.retrieveWithTotals(
cartId,
{
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"shipping_total",
"gift_card_total",
],
relations: [
"items",
"items.adjustments",
@@ -2069,7 +2108,6 @@ class CartService extends TransactionBaseService {
this.cartRepository_
)
const validatedId = validateId(cartId)
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
@@ -2077,7 +2115,7 @@ class CartService extends TransactionBaseService {
)
}
const cart = await cartRepo.findOne(validatedId)
const cart = await cartRepo.findOne(cartId)
if (!cart) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
@@ -2151,6 +2189,77 @@ class CartService extends TransactionBaseService {
)
}
async decorateTotals(cart: Cart, totalsConfig?: TotalsConfig): Promise<Cart> {
const totalsService = this.totalsService_
const calculationContext = await totalsService.getCalculationContext(cart, {
exclude_shipping: true,
})
cart.items = await Promise.all(
(cart.items || []).map(async (item) => {
const itemTotals = await totalsService.getLineItemTotals(item, cart, {
include_tax: totalsConfig?.force_taxes || cart.region.automatic_taxes,
calculation_context: calculationContext,
})
return Object.assign(item, itemTotals)
})
)
cart.shipping_methods = await Promise.all(
(cart.shipping_methods || []).map(async (shippingMethod) => {
const shippingTotals = await totalsService.getShippingMethodTotals(
shippingMethod,
cart,
{
include_tax:
totalsConfig?.force_taxes || cart.region.automatic_taxes,
calculation_context: calculationContext,
}
)
return Object.assign(shippingMethod, shippingTotals)
})
)
cart.shipping_total = cart.shipping_methods.reduce((acc, method) => {
return acc + (method.subtotal ?? 0)
}, 0)
cart.subtotal = cart.items.reduce((acc, item) => {
return acc + (item.subtotal ?? 0)
}, 0)
cart.discount_total = cart.items.reduce((acc, item) => {
return acc + (item.discount_total ?? 0)
}, 0)
cart.item_tax_total = cart.items.reduce((acc, item) => {
return acc + (item.tax_total ?? 0)
}, 0)
cart.shipping_tax_total = cart.shipping_methods.reduce((acc, method) => {
return acc + (method.tax_total ?? 0)
}, 0)
const giftCardTotal = await totalsService.getGiftCardTotal(cart, {
gift_cardable: cart.subtotal - cart.discount_total,
})
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
}
protected async refreshAdjustments_(cart: Cart): Promise<void> {
const transactionManager = this.transactionManager_ ?? this.manager_
+1 -2
View File
@@ -492,8 +492,7 @@ class OrderService extends TransactionBaseService {
const cartServiceTx = this.cartService_.withTransaction(manager)
const inventoryServiceTx = this.inventoryService_.withTransaction(manager)
const cart = await cartServiceTx.retrieve(cartId, {
select: ["subtotal", "total"],
const cart = await cartServiceTx.retrieveWithTotals(cartId, {
relations: [
"region",
"payment",
+34 -58
View File
@@ -42,6 +42,7 @@ type ShippingMethodTotals = {
type GetShippingMethodTotalsOptions = {
include_tax?: boolean
use_tax_lines?: boolean
calculation_context?: TaxCalculationContext
}
type LineItemTotals = {
@@ -54,18 +55,17 @@ type LineItemTotals = {
original_tax_total: number
tax_lines: LineItemTaxLine[]
discount_total: number
gift_card_total: number
}
type LineItemTotalsOptions = {
include_tax?: boolean
use_tax_lines?: boolean
exclude_gift_cards?: boolean
calculation_context?: TaxCalculationContext
}
type GetLineItemTotalOptions = {
include_tax?: boolean
exclude_gift_cards?: boolean
exclude_discounts?: boolean
}
@@ -189,9 +189,11 @@ class TotalsService extends TransactionBaseService {
cartOrOrder: Cart | Order,
opts: GetShippingMethodTotalsOptions = {}
): Promise<ShippingMethodTotals> {
const calculationContext = await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true,
})
const calculationContext =
opts.calculation_context ||
(await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true,
}))
calculationContext.shipping_methods = [shippingMethod]
const totals = {
@@ -459,42 +461,6 @@ class TotalsService extends TransactionBaseService {
}
}
if (!options.exclude_gift_cards) {
let lineGiftCards: LineDiscountAmount[] = []
if (orderOrCart.gift_cards && orderOrCart.gift_cards.length) {
const subtotal = await this.getSubtotal(orderOrCart)
const giftCardTotal = await this.getGiftCardTotal(orderOrCart)
// If the fixed discount exceeds the subtotal we should
// calculate a 100% discount
const nominator = Math.min(giftCardTotal.total, subtotal)
const percentage = nominator / subtotal
lineGiftCards = orderOrCart.items.map((l) => {
return {
item: l,
amount: Math.round(l.unit_price * l.quantity * percentage),
}
})
}
for (const lgc of lineGiftCards) {
if (allocationMap[lgc.item.id]) {
allocationMap[lgc.item.id].gift_card = {
amount: lgc.amount,
unit_amount: Math.round(lgc.amount / lgc.item.quantity),
}
} else {
allocationMap[lgc.item.id] = {
gift_card: {
amount: lgc.amount,
unit_amount: Math.round(lgc.amount / lgc.item.quantity),
},
}
}
}
}
return allocationMap
}
@@ -784,10 +750,12 @@ class TotalsService extends TransactionBaseService {
cartOrOrder: Cart | Order,
options: LineItemTotalsOptions = {}
): Promise<LineItemTotals> {
const calculationContext = await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true,
exclude_gift_cards: options.exclude_gift_cards,
})
const calculationContext =
options.calculation_context ||
(await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true,
exclude_gift_cards: options.exclude_gift_cards,
}))
const lineItemAllocation =
calculationContext.allocation_map[lineItem.id] || {}
@@ -802,7 +770,6 @@ class TotalsService extends TransactionBaseService {
subtotal = 0 // in that case we need to know the tax rate to compute it later
}
const gift_card_total = lineItemAllocation.gift_card?.amount || 0
const discount_total =
(lineItemAllocation.discount?.unit_amount || 0) * lineItem.quantity
@@ -810,7 +777,6 @@ class TotalsService extends TransactionBaseService {
unit_price: lineItem.unit_price,
quantity: lineItem.quantity,
subtotal,
gift_card_total,
discount_total,
total: subtotal - discount_total,
original_total: subtotal,
@@ -899,12 +865,16 @@ class TotalsService extends TransactionBaseService {
lineItemTotals.tax_lines,
calculationContext
)
calculationContext.allocation_map = {} // Don't account for discounts
const noDiscountContext = {
...calculationContext,
allocation_map: {}, // Don't account for discounts
}
lineItemTotals.original_tax_total =
await this.taxCalculationStrategy_.calculate(
[lineItem],
lineItemTotals.tax_lines,
calculationContext
noDiscountContext
)
if (
@@ -949,10 +919,6 @@ class TotalsService extends TransactionBaseService {
toReturn += lineItemTotals.discount_total
}
if (!options.exclude_gift_cards) {
toReturn += lineItemTotals.gift_card_total
}
if (options.include_tax) {
toReturn += lineItemTotals.tax_total
}
@@ -983,13 +949,21 @@ class TotalsService extends TransactionBaseService {
* @param cartOrOrder - the cart or order to get gift card amount for
* @return the gift card amount applied to the cart or order
*/
async getGiftCardTotal(cartOrOrder: Cart | Order): Promise<{
async getGiftCardTotal(
cartOrOrder: Cart | Order,
opts: { gift_cardable?: number } = {}
): Promise<{
total: number
tax_total: number
}> {
const subtotal = await this.getSubtotal(cartOrOrder)
const discountTotal = await this.getDiscountTotal(cartOrOrder)
const giftCardable = subtotal - discountTotal
let giftCardable: number
if (typeof opts.gift_cardable !== "undefined") {
giftCardable = opts.gift_cardable
} else {
const subtotal = await this.getSubtotal(cartOrOrder)
const discountTotal = await this.getDiscountTotal(cartOrOrder)
giftCardable = subtotal - discountTotal
}
if ("gift_card_transactions" in cartOrOrder) {
// gift_card_transactions only exist on orders so we can
@@ -1040,7 +1014,9 @@ class TotalsService extends TransactionBaseService {
if (cartOrOrder.region?.gift_cards_taxable) {
return {
total: orderGiftCardAmount,
tax_total: (orderGiftCardAmount * cartOrOrder.region.tax_rate) / 100,
tax_total: Math.round(
(orderGiftCardAmount * cartOrOrder.region.tax_rate) / 100
),
}
}
@@ -184,6 +184,7 @@ describe("CartCompletionStrategy", () => {
deleteTaxLines: jest.fn(() => Promise.resolve(cart)),
authorizePayment: jest.fn(() => Promise.resolve(cart)),
retrieve: jest.fn(() => Promise.resolve(cart)),
retrieveWithTotals: jest.fn(() => Promise.resolve(cart)),
}
const orderServiceMock = {
withTransaction: function () {
@@ -160,14 +160,8 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
async (manager: EntityManager) => {
const cart = await cartService
.withTransaction(manager)
.retrieve(id, {
select: ["total"],
relations: [
"items",
"items.adjustments",
"payment",
"payment_sessions",
],
.retrieveWithTotals(id, {
relations: ["payment", "payment_sessions"],
})
// If cart is part of swap, we register swap as complete