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 authored and GitHub committed 2022-10-07 08:44:06 +00:00
1 parent 527c587d8f
commit 3d255302b0
45 files changed
+10945 -8057

No files matched your search

+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 () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd }) medusaProcess = await setupServer({ cwd, verbose: false })
}) })
afterAll(async () => { afterAll(async () => {
@@ -495,7 +495,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
const expectedItemTotals = { const expectedItemTotals = {
subtotal: 200, subtotal: 200,
gift_card_total: 0,
discount_total: 30, discount_total: 30,
total: 204, total: 204,
original_total: 240, original_total: 240,
@@ -28,7 +28,7 @@ describe("Automatic Cart Taxes", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd }) medusaProcess = await setupServer({ cwd, verbose: false })
}) })
afterAll(async () => { 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_total).toEqual(110)
expect(res.data.cart.items[0].original_tax_total).toEqual(10) 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].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 () => { 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_total).toEqual(110)
expect(res.data.cart.items[0].original_tax_total).toEqual(10) 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].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 () => { 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_total).toEqual(100)
expect(res.data.cart.items[0].original_tax_total).toEqual(0) 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].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 () => { 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_total).toEqual(110)
expect(res.data.cart.items[0].original_tax_total).toEqual(10) 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].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 () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd }) medusaProcess = await setupServer({ cwd, verbose: false })
}) })
afterAll(async () => { afterAll(async () => {
@@ -76,7 +76,7 @@ describe("Manual Cart Taxes", () => {
const response = await api.get("/store/carts/test-cart") const response = await api.get("/store/carts/test-cart")
expect(response.status).toEqual(200) 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) 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", "title": "Intelligent Plastic Chips",
"totals": Object { "totals": Object {
"discount_total": 0, "discount_total": 0,
"gift_card_total": 0,
"original_tax_total": 200, "original_tax_total": 200,
"original_total": 1200, "original_total": 1200,
"quantity": 1, "quantity": 1,
@@ -1022,7 +1021,6 @@ Object {
"title": "Intelligent Plastic Chips", "title": "Intelligent Plastic Chips",
"totals": Object { "totals": Object {
"discount_total": 0, "discount_total": 0,
"gift_card_total": 0,
"original_tax_total": 400, "original_tax_total": 400,
"original_total": 2400, "original_total": 2400,
"quantity": 2, "quantity": 2,
@@ -1477,7 +1475,6 @@ Object {
"title": "Intelligent Plastic Chips", "title": "Intelligent Plastic Chips",
"totals": Object { "totals": Object {
"discount_total": 0, "discount_total": 0,
"gift_card_total": 0,
"original_tax_total": 200, "original_tax_total": 200,
"original_total": 1200, "original_total": 1200,
"quantity": 1, "quantity": 1,
+5 -5
View File
@@ -8,18 +8,18 @@
"build": "babel src -d dist --extensions \".ts,.js\"" "build": "babel src -d dist --extensions \".ts,.js\""
}, },
"dependencies": { "dependencies": {
"@medusajs/medusa": "1.2.1-dev-1649009241281", "@medusajs/medusa": "1.4.1-dev-1665082901122",
"faker": "^5.5.3", "faker": "^5.5.3",
"medusa-fulfillment-webshipper": "1.2.1-dev-1649009241281", "medusa-fulfillment-webshipper": "1.3.3-dev-1665082901122",
"medusa-interfaces": "1.2.1-dev-1649009241281", "medusa-interfaces": "1.3.3-dev-1665082901122",
"medusa-plugin-sendgrid": "1.2.1-dev-1649009241281", "medusa-plugin-sendgrid": "1.3.3-dev-1665082901122",
"typeorm": "^0.2.31" "typeorm": "^0.2.31"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.12.10", "@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10", "@babel/core": "^7.12.10",
"@babel/node": "^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" "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", () => { it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2) expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -75,7 +76,8 @@ describe("POST /store/carts/:id/shipping-methods", () => {
}) })
it("calls CartService retrieve", () => { it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2) expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -124,7 +126,8 @@ describe("POST /store/carts/:id/shipping-methods", () => {
}) })
it("calls CartService retrieve", () => { it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2) expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -35,7 +35,7 @@ describe("POST /store/carts", () => {
}) })
it("calls CartService retrieve", () => { it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1) expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -25,7 +25,8 @@ describe("POST /store/carts/:id", () => {
}) })
it("calls CartService retrieve", () => { it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(3) expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("calls LineItemService generate", () => { it("calls LineItemService generate", () => {
@@ -22,7 +22,7 @@ describe("POST /store/carts/:id/payment-sessions", () => {
}) })
it("calls Cart service retrieve", () => { it("calls Cart service retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1) expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -15,7 +15,8 @@ describe("GET /store/carts", () => {
}) })
it("calls retrieve from CartService", () => { it("calls retrieve from CartService", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2) expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns cart", () => { it("returns cart", () => {
@@ -41,7 +42,7 @@ describe("GET /store/carts", () => {
it("calls get product from productSerice", () => { it("calls get product from productSerice", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1) expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieve).toHaveBeenCalledWith("none", { 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", () => { it("calls CartService retrive", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1) expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -61,14 +61,15 @@ describe("POST /store/carts/:id", () => {
}) })
it("calls get product from productService", () => { 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( expect(CartServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("emptyCart"), IdMap.getId("emptyCart"),
{ {
relations: ["payment_sessions", "shipping_methods"], relations: ["payment_sessions", "shipping_methods"],
} }
) )
expect(CartServiceMock.retrieve).toHaveBeenCalledWith( expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledWith(
IdMap.getId("emptyCart"), IdMap.getId("emptyCart"),
{ {
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
@@ -39,7 +39,8 @@ describe("POST /store/carts/:id/line-items/:line_id", () => {
}) })
it("calls CartService retrieve", () => { it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(3) expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(2)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -42,7 +42,7 @@ describe("POST /store/carts/:id/payment-session/update", () => {
}) })
it("calls CartService retrive", () => { it("calls CartService retrive", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1) expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
}) })
it("returns 200", () => { it("returns 200", () => {
@@ -3,7 +3,6 @@ import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { validator } from "../../../../utils/validator" 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, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(updatedCart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
@@ -2,7 +2,6 @@ import { CartService, IdempotencyKeyService } from "../../../../services"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { IdempotencyKey } from "../../../../models/idempotency-key" import { IdempotencyKey } from "../../../../models/idempotency-key"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/** /**
* @oas [post] /carts/{id}/taxes * @oas [post] /carts/{id}/taxes
@@ -49,11 +48,11 @@ export default async (req, res) => {
const headerKey = req.get("Idempotency-Key") || "" const headerKey = req.get("Idempotency-Key") || ""
let idempotencyKey let idempotencyKey: IdempotencyKey
try { try {
await manager.transaction(async (transactionManager) => { idempotencyKey = await manager.transaction(async (transactionManager) => {
idempotencyKey = await idempotencyKeyService return await idempotencyKeyService
.withTransaction(transactionManager) .withTransaction(transactionManager)
.initializeRequest(headerKey, req.method, req.params, req.path) .initializeRequest(headerKey, req.method, req.params, req.path)
}) })
@@ -82,29 +81,11 @@ export default async (req, res) => {
async (manager: EntityManager) => { async (manager: EntityManager) => {
const cart = await cartService const cart = await cartService
.withTransaction(manager) .withTransaction(manager)
.retrieve( .retrieveWithTotals(id, {}, { force_taxes: true })
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,
})
return { return {
response_code: 200, response_code: 200,
response_body: { cart: data }, response_body: { cart },
} }
} }
) )
@@ -113,7 +94,7 @@ export default async (req, res) => {
inProgress = false inProgress = false
err = error err = error
} else { } else {
idempotencyKey = key idempotencyKey = key!
} }
}) })
break break
@@ -21,7 +21,6 @@ import { Cart } from "../../../../models"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { FlagRouter } from "../../../../utils/flag-router" import { FlagRouter } from "../../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { CartCreateProps } from "../../../../types/cart" import { CartCreateProps } from "../../../../types/cart"
import { isDefined } from "../../../../utils" 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, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req) res.status(200).json({ cart })
res.status(200).json({ cart: data })
} }
export class Item { export class Item {
@@ -3,7 +3,6 @@ import { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService, LineItemService } from "../../../../services" import { CartService, LineItemService } from "../../../../services"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { FlagRouter } from "../../../../utils/flag-router" 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, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
@@ -1,6 +1,5 @@
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import IdempotencyKeyService from "../../../../services/idempotency-key" import IdempotencyKeyService from "../../../../services/idempotency-key"
@@ -92,19 +91,14 @@ export default async (req, res) => {
const cart = await cartService const cart = await cartService
.withTransaction(stageManager) .withTransaction(stageManager)
.retrieve(id, { .retrieveWithTotals(id, {
select: defaultStoreCartFields, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req, {
force_taxes: false,
transactionManager: stageManager,
})
return { return {
response_code: 200, 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 { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/** /**
* @oas [delete] /carts/{id}/discounts/{code} * @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, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
@@ -1,7 +1,6 @@
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/** /**
* @oas [delete] /carts/{id}/line-items/{line_id} * @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, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
@@ -1,6 +1,5 @@
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
/** /**
@@ -59,11 +58,10 @@ export default async (req, res) => {
.deletePaymentSession(id, provider_id) .deletePaymentSession(id, provider_id)
}) })
const cart = await cartService.retrieve(id, { const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
@@ -1,5 +1,4 @@
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/** /**
* @oas [get] /carts/{id} * @oas [get] /carts/{id}
@@ -49,8 +48,8 @@ export default async (req, res) => {
const cartService: CartService = req.scope.resolve("cartService") const cartService: CartService = req.scope.resolve("cartService")
let cart = await cartService.retrieve(id, { const cart = await cartService.retrieve(id, {
relations: ["customer"], select: ["id", "customer_id"],
}) })
// If there is a logged in user add the user to the cart // 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 cartService.retrieveWithTotals(id, req.retrieveConfig)
const data = await decorateLineItemsWithTotals(cart, req)
res.json({ cart: data }) res.json({ cart: data })
} }
@@ -121,14 +121,7 @@ export default (app, container) => {
return app return app
} }
export const defaultStoreCartFields: (keyof Cart)[] = [ export const defaultStoreCartFields: (keyof Cart)[] = []
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"gift_card_total",
"total",
]
export const defaultStoreCartRelations = [ export const defaultStoreCartRelations = [
"gift_cards", "gift_cards",
@@ -1,5 +1,4 @@
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
/** /**
@@ -57,14 +56,7 @@ export default async (req, res) => {
.withTransaction(transactionManager) .withTransaction(transactionManager)
.refreshPaymentSession(id, provider_id) .refreshPaymentSession(id, provider_id)
}) })
const cart = await cartService.retrieve(id, { const data = await cartService.retrieveWithTotals(id, {
select: [
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"total",
],
relations: [ relations: [
"region", "region",
"region.countries", "region.countries",
@@ -75,6 +67,5 @@ export default async (req, res) => {
], ],
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
@@ -3,7 +3,6 @@ import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { IsString } from "class-validator" import { IsString } from "class-validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
/** /**
@@ -73,12 +72,11 @@ export default async (req, res) => {
.setPaymentSession(id, validated.provider_id) .setPaymentSession(id, validated.provider_id)
}) })
const cart = await cartService.retrieve(id, { const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) 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 { IsType } from "../../../../utils/validators/is-type"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/** /**
* @oas [post] /carts/{id} * @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, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.json({ cart: data }) res.json({ cart: data })
} }
@@ -4,7 +4,6 @@ import { EntityManager } from "typeorm"
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
/** /**
* @oas [post] /carts/{id}/line-items/{line_id} * @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, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
@@ -2,7 +2,6 @@ import { IsObject } from "class-validator"
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
/** /**
@@ -78,11 +77,10 @@ export default async (req, res) => {
.updatePaymentSession(id, validated.data) .updatePaymentSession(id, validated.data)
}) })
const cart = await cartService.retrieve(id, { const data = await cartService.retrieveWithTotals(id, {
select: defaultStoreCartFields, select: defaultStoreCartFields,
relations: defaultStoreCartRelations, relations: defaultStoreCartRelations,
}) })
const data = await decorateLineItemsWithTotals(cart, req)
res.status(200).json({ cart: data }) res.status(200).json({ cart: data })
} }
+2
View File
@@ -333,6 +333,8 @@ export class Cart extends SoftDeletableEntity {
shipping_total?: number shipping_total?: number
discount_total?: number discount_total?: number
item_tax_total?: number | null
shipping_tax_total?: number | null
tax_total?: number | null tax_total?: number | null
refunded_total?: number refunded_total?: number
total?: number total?: number
@@ -94,6 +94,10 @@ export class ShippingMethod {
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false }) @FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax: boolean includes_tax: boolean
subtotal?: number
total?: number
tax_total?: number
@BeforeInsert() @BeforeInsert()
private beforeInsert(): void { private beforeInsert(): void {
this.id = generateEntityId(this.id, "sm") this.id = generateEntityId(this.id, "sm")
+35 -3
View File
@@ -39,10 +39,12 @@ export const carts = {
}, },
total: 1000, total: 1000,
region_id: IdMap.getId("testRegion"), region_id: IdMap.getId("testRegion"),
shipping_options: [{ shipping_options: [
{
id: IdMap.getId("tax-inclusive-option"), id: IdMap.getId("tax-inclusive-option"),
includes_tax: true includes_tax: true,
}], },
],
}, },
testSwapCart: { testSwapCart: {
id: IdMap.getId("test-swap"), id: IdMap.getId("test-swap"),
@@ -254,6 +256,36 @@ export const CartServiceMock = {
} }
return Promise.resolve(carts.regionCart) 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) => { retrieve: jest.fn().mockImplementation((cartId) => {
if (cartId === IdMap.getId("fr-cart")) { if (cartId === IdMap.getId("fr-cart")) {
return Promise.resolve(carts.frCart) return Promise.resolve(carts.frCart)
+15 -1
View File
@@ -18,6 +18,13 @@ describe("CartService", () => {
withTransaction: function () { withTransaction: function () {
return this return this
}, },
getShippingMethodTotals: (m) => {
return m
},
getLineItemTotals: (i) => {
return i
},
getCalculationContext: () => {},
getTotal: (o) => { getTotal: (o) => {
return o.total || 0 return o.total || 0
}, },
@@ -849,10 +856,12 @@ describe("CartService", () => {
} }
return Promise.resolve({ return Promise.resolve({
id: IdMap.getId("cartWithLine"), id: IdMap.getId("cartWithLine"),
total: 100,
items: [ items: [
{ {
id: IdMap.getId("existing"), id: IdMap.getId("existing"),
variant_id: IdMap.getId("good"), variant_id: IdMap.getId("good"),
subtotal: 100,
quantity: 1, quantity: 1,
}, },
], ],
@@ -1370,6 +1379,7 @@ describe("CartService", () => {
describe("setPaymentSessions", () => { describe("setPaymentSessions", () => {
const cart1 = { const cart1 = {
total: 100, total: 100,
items: [{ subtotal: 100 }],
payment_sessions: [], payment_sessions: [],
region: { region: {
payment_providers: [{ id: "provider_1" }, { id: "provider_2" }], payment_providers: [{ id: "provider_1" }, { id: "provider_2" }],
@@ -1386,6 +1396,8 @@ describe("CartService", () => {
const cart3 = { const cart3 = {
total: 100, total: 100,
items: [{ subtotal: 100 }],
shipping_methods: [{ subtotal: 100 }],
payment_sessions: [ payment_sessions: [
{ provider_id: "provider_1" }, { provider_id: "provider_1" },
{ provider_id: "not_in_region" }, { provider_id: "not_in_region" },
@@ -1397,6 +1409,8 @@ describe("CartService", () => {
const cart4 = { const cart4 = {
total: 0, total: 0,
items: [{ total: 0 }],
shipping_methods: [],
payment_sessions: [ payment_sessions: [
{ provider_id: "provider_1" }, { provider_id: "provider_1" },
{ provider_id: "provider_2" }, { provider_id: "provider_2" },
@@ -1577,7 +1591,7 @@ describe("CartService", () => {
shipping_methods: [{ id: "ship1", profile: "profile1" }], shipping_methods: [{ id: "ship1", profile: "profile1" }],
}) })
const cart3 = buildCart("lines", { const cart3 = buildCart("lines", {
items: [{ id: "line", profile: "profile1" }], items: [{ id: "line", profile: "profile1", subtotal: 100 }],
}) })
const cartWithCustomSO = buildCart("cart-with-custom-so") const cartWithCustomSO = buildCart("cart-with-custom-so")
+10 -11
View File
@@ -91,7 +91,7 @@ describe("OrderService", () => {
total: 0, total: 0,
} }
const cartService = { const cartService = {
retrieve: jest.fn().mockImplementation((query) => { retrieveWithTotals: jest.fn().mockImplementation((query) => {
if (query === "empty") { if (query === "empty") {
return Promise.resolve(emptyCart) return Promise.resolve(emptyCart)
} }
@@ -180,7 +180,9 @@ describe("OrderService", () => {
total: 100, 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()) orderService.cartService_.update = jest.fn(() => Promise.resolve())
await orderService.createFromCart("cart_id") await orderService.createFromCart("cart_id")
@@ -199,9 +201,8 @@ describe("OrderService", () => {
metadata: {}, metadata: {},
} }
expect(cartService.retrieve).toHaveBeenCalledTimes(1) expect(cartService.retrieveWithTotals).toHaveBeenCalledTimes(1)
expect(cartService.retrieve).toHaveBeenCalledWith("cart_id", { expect(cartService.retrieveWithTotals).toHaveBeenCalledWith("cart_id", {
select: ["subtotal", "total"],
relations: [ relations: [
"region", "region",
"payment", "payment",
@@ -283,7 +284,7 @@ describe("OrderService", () => {
total: 100, total: 100,
} }
orderService.cartService_.retrieve = () => { orderService.cartService_.retrieveWithTotals = () => {
return Promise.resolve(cart) return Promise.resolve(cart)
} }
orderService.cartService_.update = () => Promise.resolve() orderService.cartService_.update = () => Promise.resolve()
@@ -374,7 +375,7 @@ describe("OrderService", () => {
], ],
total: 0, total: 0,
} }
orderService.cartService_.retrieve = () => Promise.resolve(cart) orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart)
await orderService.createFromCart(cart) await orderService.createFromCart(cart)
const order = { const order = {
payment_status: "awaiting", payment_status: "awaiting",
@@ -432,7 +433,7 @@ describe("OrderService", () => {
], ],
total: 100, total: 100,
} }
orderService.cartService_.retrieve = () => Promise.resolve(cart) orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart)
orderService.cartService_.update = () => Promise.resolve() orderService.cartService_.update = () => Promise.resolve()
const res = orderService.createFromCart(cart) const res = orderService.createFromCart(cart)
await expect(res).rejects.toThrow( await expect(res).rejects.toThrow(
@@ -1400,9 +1401,7 @@ describe("OrderService", () => {
) )
expect(fulfillmentService.createShipment).toHaveBeenCalledTimes(1) expect(fulfillmentService.createShipment).toHaveBeenCalledTimes(1)
expect( expect(fulfillmentService.createShipment).toHaveBeenCalledWith(
fulfillmentService.createShipment
).toHaveBeenCalledWith(
IdMap.getId("fulfillment"), IdMap.getId("fulfillment"),
[{ tracking_number: "1234" }, { tracking_number: "2345" }], [{ tracking_number: "1234" }, { tracking_number: "2345" }],
{ metadata: undefined, no_notification: true } { metadata: undefined, no_notification: true }
+136 -27
View File
@@ -26,7 +26,7 @@ import {
LineItemUpdate, LineItemUpdate,
} from "../types/cart" } from "../types/cart"
import { AddressPayload, FindConfig, TotalField } from "../types/common" 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 { FlagRouter } from "../utils/flag-router"
import { validateEmail } from "../utils/is-email" import { validateEmail } from "../utils/is-email"
import CustomShippingOptionService from "./custom-shipping-option" import CustomShippingOptionService from "./custom-shipping-option"
@@ -175,6 +175,24 @@ class CartService extends TransactionBaseService {
this.storeService_ = storeService 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_( protected transformQueryForTotals_(
config: FindConfig<Cart> config: FindConfig<Cart>
): FindConfig<Cart> & { totalsToSelect: TotalField[] } { ): FindConfig<Cart> & { totalsToSelect: TotalField[] } {
@@ -297,7 +315,6 @@ class CartService extends TransactionBaseService {
* Gets a cart by id. * Gets a cart by id.
* @param cartId - the id of the cart to get. * @param cartId - the id of the cart to get.
* @param options - the options to get a cart * @param options - the options to get a cart
* @param totalsConfig - configuration for retrieval of totals
* @return the cart document. * @return the cart document.
*/ */
async retrieve( async retrieve(
@@ -307,15 +324,11 @@ class CartService extends TransactionBaseService {
): Promise<Cart> { ): Promise<Cart> {
const manager = this.manager_ const manager = this.manager_
const cartRepo = manager.getCustomRepository(this.cartRepository_) const cartRepo = manager.getCustomRepository(this.cartRepository_)
const validatedId = validateId(cartId)
const { select, relations, totalsToSelect } = const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(options) this.transformQueryForTotals_(options)
const query = buildQuery( const query = buildQuery({ id: cartId }, { ...options, select, relations })
{ id: validatedId },
{ ...options, select, relations }
)
if (relations && relations.length > 0) { if (relations && relations.length > 0) {
query.relations = relations query.relations = relations
@@ -327,6 +340,32 @@ class CartService extends TransactionBaseService {
query.select = undefined 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 const queryRelations = query.relations
query.relations = undefined 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_ this.paymentSessionRepository_
) )
const cart = await this.retrieve(cartId, { const cart = await this.retrieveWithTotals(cartId, {
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"gift_card_total",
],
relations: ["region", "region.payment_providers", "payment_sessions"], relations: ["region", "region.payment_providers", "payment_sessions"],
}) })
@@ -1503,17 +1550,9 @@ class CartService extends TransactionBaseService {
const cartId = const cartId =
typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id
const cart = await this.retrieve( const cart = await this.retrieveWithTotals(
cartId, cartId,
{ {
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"shipping_total",
"gift_card_total",
],
relations: [ relations: [
"items", "items",
"items.adjustments", "items.adjustments",
@@ -2069,7 +2108,6 @@ class CartService extends TransactionBaseService {
this.cartRepository_ this.cartRepository_
) )
const validatedId = validateId(cartId)
if (typeof key !== "string") { if (typeof key !== "string") {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT, 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) { if (!cart) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_FOUND, 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> { protected async refreshAdjustments_(cart: Cart): Promise<void> {
const transactionManager = this.transactionManager_ ?? this.manager_ 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 cartServiceTx = this.cartService_.withTransaction(manager)
const inventoryServiceTx = this.inventoryService_.withTransaction(manager) const inventoryServiceTx = this.inventoryService_.withTransaction(manager)
const cart = await cartServiceTx.retrieve(cartId, { const cart = await cartServiceTx.retrieveWithTotals(cartId, {
select: ["subtotal", "total"],
relations: [ relations: [
"region", "region",
"payment", "payment",
+29 -53
View File
@@ -42,6 +42,7 @@ type ShippingMethodTotals = {
type GetShippingMethodTotalsOptions = { type GetShippingMethodTotalsOptions = {
include_tax?: boolean include_tax?: boolean
use_tax_lines?: boolean use_tax_lines?: boolean
calculation_context?: TaxCalculationContext
} }
type LineItemTotals = { type LineItemTotals = {
@@ -54,18 +55,17 @@ type LineItemTotals = {
original_tax_total: number original_tax_total: number
tax_lines: LineItemTaxLine[] tax_lines: LineItemTaxLine[]
discount_total: number discount_total: number
gift_card_total: number
} }
type LineItemTotalsOptions = { type LineItemTotalsOptions = {
include_tax?: boolean include_tax?: boolean
use_tax_lines?: boolean use_tax_lines?: boolean
exclude_gift_cards?: boolean exclude_gift_cards?: boolean
calculation_context?: TaxCalculationContext
} }
type GetLineItemTotalOptions = { type GetLineItemTotalOptions = {
include_tax?: boolean include_tax?: boolean
exclude_gift_cards?: boolean
exclude_discounts?: boolean exclude_discounts?: boolean
} }
@@ -189,9 +189,11 @@ class TotalsService extends TransactionBaseService {
cartOrOrder: Cart | Order, cartOrOrder: Cart | Order,
opts: GetShippingMethodTotalsOptions = {} opts: GetShippingMethodTotalsOptions = {}
): Promise<ShippingMethodTotals> { ): Promise<ShippingMethodTotals> {
const calculationContext = await this.getCalculationContext(cartOrOrder, { const calculationContext =
opts.calculation_context ||
(await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true, exclude_shipping: true,
}) }))
calculationContext.shipping_methods = [shippingMethod] calculationContext.shipping_methods = [shippingMethod]
const totals = { 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 return allocationMap
} }
@@ -784,10 +750,12 @@ class TotalsService extends TransactionBaseService {
cartOrOrder: Cart | Order, cartOrOrder: Cart | Order,
options: LineItemTotalsOptions = {} options: LineItemTotalsOptions = {}
): Promise<LineItemTotals> { ): Promise<LineItemTotals> {
const calculationContext = await this.getCalculationContext(cartOrOrder, { const calculationContext =
options.calculation_context ||
(await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true, exclude_shipping: true,
exclude_gift_cards: options.exclude_gift_cards, exclude_gift_cards: options.exclude_gift_cards,
}) }))
const lineItemAllocation = const lineItemAllocation =
calculationContext.allocation_map[lineItem.id] || {} 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 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 = const discount_total =
(lineItemAllocation.discount?.unit_amount || 0) * lineItem.quantity (lineItemAllocation.discount?.unit_amount || 0) * lineItem.quantity
@@ -810,7 +777,6 @@ class TotalsService extends TransactionBaseService {
unit_price: lineItem.unit_price, unit_price: lineItem.unit_price,
quantity: lineItem.quantity, quantity: lineItem.quantity,
subtotal, subtotal,
gift_card_total,
discount_total, discount_total,
total: subtotal - discount_total, total: subtotal - discount_total,
original_total: subtotal, original_total: subtotal,
@@ -899,12 +865,16 @@ class TotalsService extends TransactionBaseService {
lineItemTotals.tax_lines, lineItemTotals.tax_lines,
calculationContext calculationContext
) )
calculationContext.allocation_map = {} // Don't account for discounts const noDiscountContext = {
...calculationContext,
allocation_map: {}, // Don't account for discounts
}
lineItemTotals.original_tax_total = lineItemTotals.original_tax_total =
await this.taxCalculationStrategy_.calculate( await this.taxCalculationStrategy_.calculate(
[lineItem], [lineItem],
lineItemTotals.tax_lines, lineItemTotals.tax_lines,
calculationContext noDiscountContext
) )
if ( if (
@@ -949,10 +919,6 @@ class TotalsService extends TransactionBaseService {
toReturn += lineItemTotals.discount_total toReturn += lineItemTotals.discount_total
} }
if (!options.exclude_gift_cards) {
toReturn += lineItemTotals.gift_card_total
}
if (options.include_tax) { if (options.include_tax) {
toReturn += lineItemTotals.tax_total toReturn += lineItemTotals.tax_total
} }
@@ -983,13 +949,21 @@ class TotalsService extends TransactionBaseService {
* @param cartOrOrder - the cart or order to get gift card amount for * @param cartOrOrder - the cart or order to get gift card amount for
* @return the gift card amount applied to the cart or order * @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 total: number
tax_total: number tax_total: number
}> { }> {
let giftCardable: number
if (typeof opts.gift_cardable !== "undefined") {
giftCardable = opts.gift_cardable
} else {
const subtotal = await this.getSubtotal(cartOrOrder) const subtotal = await this.getSubtotal(cartOrOrder)
const discountTotal = await this.getDiscountTotal(cartOrOrder) const discountTotal = await this.getDiscountTotal(cartOrOrder)
const giftCardable = subtotal - discountTotal giftCardable = subtotal - discountTotal
}
if ("gift_card_transactions" in cartOrOrder) { if ("gift_card_transactions" in cartOrOrder) {
// gift_card_transactions only exist on orders so we can // gift_card_transactions only exist on orders so we can
@@ -1040,7 +1014,9 @@ class TotalsService extends TransactionBaseService {
if (cartOrOrder.region?.gift_cards_taxable) { if (cartOrOrder.region?.gift_cards_taxable) {
return { return {
total: orderGiftCardAmount, 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)), deleteTaxLines: jest.fn(() => Promise.resolve(cart)),
authorizePayment: jest.fn(() => Promise.resolve(cart)), authorizePayment: jest.fn(() => Promise.resolve(cart)),
retrieve: jest.fn(() => Promise.resolve(cart)), retrieve: jest.fn(() => Promise.resolve(cart)),
retrieveWithTotals: jest.fn(() => Promise.resolve(cart)),
} }
const orderServiceMock = { const orderServiceMock = {
withTransaction: function () { withTransaction: function () {
@@ -160,14 +160,8 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
async (manager: EntityManager) => { async (manager: EntityManager) => {
const cart = await cartService const cart = await cartService
.withTransaction(manager) .withTransaction(manager)
.retrieve(id, { .retrieveWithTotals(id, {
select: ["total"], relations: ["payment", "payment_sessions"],
relations: [
"items",
"items.adjustments",
"payment",
"payment_sessions",
],
}) })
// If cart is part of swap, we register swap as complete // If cart is part of swap, we register swap as complete