Skip to content

Commit

Permalink
feat(medusa): Prevent cart completion conflict (#5814)
Browse files Browse the repository at this point in the history
  • Loading branch information
adrien2p authored Dec 19, 2023
1 parent 9cc787c commit 496dcf1
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 122 deletions.
5 changes: 5 additions & 0 deletions .changeset/smooth-days-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---

Feat/cart completion conflict fixes
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe("POST /store/carts/:id", () => {

it("calls CartService retrieve", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(2)
})

it("calls LineItemService generate", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe("POST /store/carts/:id/payment-sessions", () => {
})

it("calls Cart service retrieve", () => {
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
expect(CartServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(2)
})

it("returns 200", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { validator } from "../../../../../utils/validator"
import {
addOrUpdateLineItem,
CreateLineItemSteps,
setPaymentSession,
setPaymentSessions,
setVariantAvailability,
} from "./utils/handler-steps"
import { IdempotencyKey } from "../../../../../models"
Expand All @@ -13,7 +13,6 @@ import { cleanResponseData } from "../../../../../utils/clean-response-data"
import IdempotencyKeyService from "../../../../../services/idempotency-key"
import { defaultStoreCartFields, defaultStoreCartRelations } from "../index"
import { CartService } from "../../../../../services"
import { promiseAll } from "@medusajs/utils"

/**
* @oas [post] /store/carts/{id}/line-items
Expand Down Expand Up @@ -130,37 +129,42 @@ export default async (req, res) => {
case CreateLineItemSteps.SET_PAYMENT_SESSIONS: {
try {
const cartService: CartService = req.scope.resolve("cartService")
const getCart = async () => {
return await cartService
.withTransaction(manager)
.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: [
...defaultStoreCartRelations,
"region.tax_rates",
"customer",
],
})
}

const cart = await cartService
.withTransaction(manager)
.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: [
...defaultStoreCartRelations,
"billing_address",
"region.payment_providers",
"payment_sessions",
"customer",
],
const cart = await getCart()

await manager.transaction(async (transactionManager) => {
await setPaymentSessions({
cart,
container: req.scope,
manager: transactionManager,
})
})

const args = {
cart,
const freshCart = await getCart()
await setVariantAvailability({
cart: freshCart,
container: req.scope,
manager,
}

await promiseAll([
setVariantAvailability(args),
setPaymentSession(args),
])
})

idempotencyKey = await idempotencyKeyService
.withTransaction(manager)
.update(idempotencyKey.idempotency_key, {
recovery_point: CreateLineItemSteps.FINISHED,
response_code: 200,
response_body: { cart },
response_body: { cart: freshCart },
})
} catch (e) {
inProgress = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function addOrUpdateLineItem({
})
}

export async function setPaymentSession({ cart, container, manager }) {
export async function setPaymentSessions({ cart, container, manager }) {
const cartService: CartService = container.resolve("cartService")

const txCartService = cartService.withTransaction(manager)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {
CartService,
ProductVariantInventoryService,
} from "../../../../services"
import { CartService } from "../../../../services"
import { defaultStoreCartFields, defaultStoreCartRelations } from "."

import { EntityManager } from "typeorm"
import IdempotencyKeyService from "../../../../services/idempotency-key"
import { cleanResponseData } from "../../../../utils/clean-response-data"
import { setVariantAvailability } from "./create-line-item/utils/handler-steps"
import { WithRequiredProperty } from "../../../../types/common"
import { Cart } from "../../../../models"

/**
* @oas [post] /store/carts/{id}/payment-sessions
Expand Down Expand Up @@ -55,14 +55,10 @@ import { cleanResponseData } from "../../../../utils/clean-response-data"
export default async (req, res) => {
const { id } = req.params

const cartService: CartService = req.scope.resolve("cartService")
const idempotencyKeyService: IdempotencyKeyService = req.scope.resolve(
"idempotencyKeyService"
)

const productVariantInventoryService: ProductVariantInventoryService =
req.scope.resolve("productVariantInventoryService")

const manager: EntityManager = req.scope.resolve("manager")

const headerKey = req.get("Idempotency-Key") || ""
Expand All @@ -88,40 +84,53 @@ export default async (req, res) => {
while (inProgress) {
switch (idempotencyKey.recovery_point) {
case "started": {
await manager
.transaction("SERIALIZABLE", async (transactionManager) => {
idempotencyKey = await idempotencyKeyService
.withTransaction(transactionManager)
.workStage(
idempotencyKey.idempotency_key,
async (stageManager) => {
await cartService
.withTransaction(stageManager)
.setPaymentSessions(id)

const cart = await cartService
.withTransaction(stageManager)
.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})

await productVariantInventoryService.setVariantAvailability(
cart.items.map((i) => i.variant),
cart.sales_channel_id!
)

return {
response_code: 200,
response_body: { cart },
}
}
try {
const cartService: CartService = req.scope.resolve("cartService")
const getCart = async () => {
return await cartService
.withTransaction(manager)
.retrieveWithTotals(
id,
{
select: defaultStoreCartFields,
relations: [
...defaultStoreCartRelations,
"region.tax_rates",
"customer",
],
},
{ force_taxes: true }
)
}

const cart = await getCart()

await manager.transaction(async (transactionManager) => {
const txCartService =
cartService.withTransaction(transactionManager)
await txCartService.setPaymentSessions(
cart as WithRequiredProperty<Cart, "total">
)
})
.catch((e) => {
inProgress = false
err = e

const freshCart = await getCart()
await setVariantAvailability({
cart: freshCart,
container: req.scope,
manager,
})

idempotencyKey = await idempotencyKeyService
.withTransaction(manager)
.update(idempotencyKey.idempotency_key, {
recovery_point: "finished",
response_code: 200,
response_body: { cart: freshCart },
})
} catch (e) {
inProgress = false
err = e
}
break
}

Expand Down
21 changes: 15 additions & 6 deletions packages/medusa/src/services/__tests__/cart.js
Original file line number Diff line number Diff line change
Expand Up @@ -1641,23 +1641,32 @@ describe("CartService", () => {
const cartRepository = MockRepository({
findOneWithRelations: (rel, q) => {
if (q.where.id === IdMap.getId("cart-to-filter")) {
return Promise.resolve(cart3)
return Promise.resolve({
id: IdMap.getId("cart-to-filter"),
...cart3,
})
}
if (q.where.id === IdMap.getId("cart-with-session")) {
return Promise.resolve(cart2)
return Promise.resolve({
id: IdMap.getId("cart-with-session"),
...cart2,
})
}
if (q.where.id === IdMap.getId("cart-remove")) {
return Promise.resolve(cart4)
return Promise.resolve({ id: IdMap.getId("cart-remove"), ...cart4 })
}
if (q.where.id === IdMap.getId("cart-negative")) {
return Promise.resolve(cart4)
return Promise.resolve({ id: IdMap.getId("cart-negative"), ...cart4 })
}
if (
q.where.id === IdMap.getId("cartWithMixedSelectedInitiatedSessions")
) {
return Promise.resolve(cart5)
return Promise.resolve({
id: IdMap.getId("cartWithMixedSelectedInitiatedSessions"),
...cart5,
})
}
return Promise.resolve(cart1)
return Promise.resolve({ id: q.where.id, ...cart1 })
},
})

Expand Down
79 changes: 42 additions & 37 deletions packages/medusa/src/services/cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1693,27 +1693,32 @@ class CartService extends TransactionBaseService {
* a payment object, that we will use to update our cart payment with.
* Additionally, if the payment does not require more or fails, we will
* set the payment on the cart.
* @param cartId - the id of the cart to authorize payment for
* @param cartOrId - the id of the cart to authorize payment for
* @param context - object containing whatever is relevant for
* authorizing the payment with the payment provider. As an example,
* this could be IP address or similar for fraud handling.
* @return the resulting cart
*/
async authorizePayment(
cartId: string,
context: Record<string, unknown> & {
cart_id: string
} = { cart_id: "" }
cartOrId: string | WithRequiredProperty<Cart, "total">,
context: Record<string, unknown> = { cart_id: "" }
): Promise<Cart> {
context = {
...context,
cart_id: isString(cartOrId) ? cartOrId : cartOrId.id,
}

return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cartRepository = transactionManager.withRepository(
this.cartRepository_
)

const cart = await this.retrieveWithTotals(cartId, {
relations: ["payment_sessions", "items.variant.product.profiles"],
})
const cart = !isString(cartOrId)
? cartOrId
: await this.retrieveWithTotals(cartOrId, {
relations: ["payment_sessions", "items.variant.product.profiles"],
})

// If cart total is 0, we don't perform anything payment related
if (cart.total! <= 0) {
Expand Down Expand Up @@ -1875,8 +1880,7 @@ class CartService extends TransactionBaseService {
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, { id: cartId })
},
"SERIALIZABLE"
}
)
}

Expand All @@ -1889,7 +1893,9 @@ class CartService extends TransactionBaseService {
* @param cartOrCartId - the id of the cart to set payment session for
* @return the result of the update operation.
*/
async setPaymentSessions(cartOrCartId: Cart | string): Promise<void> {
async setPaymentSessions(
cartOrCartId: WithRequiredProperty<Cart, "total"> | string
): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const psRepo = transactionManager.withRepository(
Expand All @@ -1899,31 +1905,30 @@ class CartService extends TransactionBaseService {
const paymentProviderServiceTx =
this.paymentProviderService_.withTransaction(transactionManager)

const cartId =
typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id

const cart = await this.retrieveWithTotals(
cartId,
{
relations: [
"items.variant.product.profiles",
"items.adjustments",
"discounts",
"discounts.rule",
"gift_cards",
"shipping_methods",
"shipping_methods.shipping_option",
"billing_address",
"shipping_address",
"region",
"region.tax_rates",
"region.payment_providers",
"payment_sessions",
"customer",
],
},
{ force_taxes: true }
)
const cart = !isString(cartOrCartId)
? cartOrCartId
: await this.retrieveWithTotals(
cartOrCartId,
{
relations: [
"items.variant.product.profiles",
"items.adjustments",
"discounts",
"discounts.rule",
"gift_cards",
"shipping_methods",
"shipping_methods.shipping_option",
"billing_address",
"shipping_address",
"region",
"region.tax_rates",
"region.payment_providers",
"payment_sessions",
"customer",
],
},
{ force_taxes: true }
)

const { total, region } = cart

Expand Down Expand Up @@ -1957,7 +1962,7 @@ class CartService extends TransactionBaseService {
currency_code: cart.region.currency_code,
}
const partialPaymentSessionData = {
cart_id: cartId,
cart_id: cart.id,
data: {},
status: PaymentSessionStatus.PENDING,
amount: total,
Expand Down
Loading

0 comments on commit 496dcf1

Please sign in to comment.