Skip to content

Commit 490bd76

Browse files
fix(core-flows): complete cart improvements (medusajs#12646)
* fix(core-flows): use cartId as transactionId and acquire lock to complete cart * fix cart update compensation
1 parent 820965e commit 490bd76

File tree

14 files changed

+356
-22
lines changed

14 files changed

+356
-22
lines changed

.changeset/proud-dancers-film.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@medusajs/workflows-sdk": patch
3+
"@medusajs/core-flows": patch
4+
"@medusajs/utils": patch
5+
---
6+
7+
fix(core-flows): use cart_id to complete cart on payment webhook and lock cart before completion

integration-tests/modules/__tests__/cart/store/cart.completion.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,135 @@ medusaIntegrationTestRunner({
747747
paymentSession.id
748748
)
749749
})
750+
it("should complete cart when payment webhook and storefront are called in simultaneously", async () => {
751+
const salesChannel = await scModuleService.createSalesChannels({
752+
name: "Webshop",
753+
})
754+
755+
const location = await stockLocationModule.createStockLocations({
756+
name: "Warehouse",
757+
})
758+
759+
const [product] = await productModule.createProducts([
760+
{
761+
title: "Test product",
762+
variants: [
763+
{
764+
title: "Test variant",
765+
manage_inventory: false,
766+
},
767+
],
768+
},
769+
])
770+
771+
const priceSet = await pricingModule.createPriceSets({
772+
prices: [
773+
{
774+
amount: 3000,
775+
currency_code: "usd",
776+
},
777+
],
778+
})
779+
780+
await pricingModule.createPricePreferences({
781+
attribute: "currency_code",
782+
value: "usd",
783+
has_tax_inclusive: true,
784+
})
785+
786+
await remoteLink.create([
787+
{
788+
[Modules.PRODUCT]: {
789+
variant_id: product.variants[0].id,
790+
},
791+
[Modules.PRICING]: {
792+
price_set_id: priceSet.id,
793+
},
794+
},
795+
{
796+
[Modules.SALES_CHANNEL]: {
797+
sales_channel_id: salesChannel.id,
798+
},
799+
[Modules.STOCK_LOCATION]: {
800+
stock_location_id: location.id,
801+
},
802+
},
803+
])
804+
805+
// create cart
806+
const cart = await cartModuleService.createCarts({
807+
currency_code: "usd",
808+
sales_channel_id: salesChannel.id,
809+
})
810+
811+
await addToCartWorkflow(appContainer).run({
812+
input: {
813+
items: [
814+
{
815+
variant_id: product.variants[0].id,
816+
quantity: 1,
817+
requires_shipping: false,
818+
},
819+
],
820+
cart_id: cart.id,
821+
},
822+
})
823+
824+
await createPaymentCollectionForCartWorkflow(appContainer).run({
825+
input: {
826+
cart_id: cart.id,
827+
},
828+
})
829+
830+
const [payCol] = await remoteQuery(
831+
remoteQueryObjectFromString({
832+
entryPoint: "cart_payment_collection",
833+
variables: { filters: { cart_id: cart.id } },
834+
fields: ["payment_collection_id"],
835+
})
836+
)
837+
838+
const { result: paymentSession } =
839+
await createPaymentSessionsWorkflow(appContainer).run({
840+
input: {
841+
payment_collection_id: payCol.payment_collection_id,
842+
provider_id: "pp_system_default",
843+
context: {},
844+
data: {},
845+
},
846+
})
847+
848+
const [{ result: order }] = await Promise.all([
849+
completeCartWorkflow(appContainer).run({
850+
input: {
851+
id: cart.id,
852+
},
853+
}),
854+
processPaymentWorkflow(appContainer).run({
855+
input: {
856+
action: "captured",
857+
data: {
858+
session_id: paymentSession.id,
859+
amount: 3000,
860+
},
861+
},
862+
}),
863+
])
864+
865+
const { result: fullOrder } = await getOrderDetailWorkflow(
866+
appContainer
867+
).run({
868+
input: {
869+
fields: ["*"],
870+
order_id: order.id,
871+
},
872+
})
873+
874+
expect(fullOrder.payment_status).toBe("captured")
875+
expect(fullOrder.payment_collections[0].authorized_amount).toBe(3000)
876+
expect(fullOrder.payment_collections[0].captured_amount).toBe(3000)
877+
expect(fullOrder.payment_collections[0].status).toBe("completed")
878+
})
750879
})
751880
})
752881
},

packages/core/core-flows/src/cart/steps/update-carts.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,18 @@ export const updateCartsStep = createStep(
2929
async (data: UpdateCartsStepInput, { container }) => {
3030
const cartModule = container.resolve<ICartModuleService>(Modules.CART)
3131

32-
const { selects, relations } = getSelectsAndRelationsFromObjectArray(data)
32+
const { selects, relations } = getSelectsAndRelationsFromObjectArray(data, {
33+
requiredFields: [
34+
"id",
35+
"region_id",
36+
"customer_id",
37+
"sales_channel_id",
38+
"email",
39+
"currency_code",
40+
"metadata",
41+
"completed_at",
42+
],
43+
})
3344
const cartsBeforeUpdate = await cartModule.listCarts(
3445
{ id: data.map((d) => d.id) },
3546
{ select: selects, relations }

packages/core/core-flows/src/cart/workflows/complete-cart.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
useQueryGraphStep,
2525
useRemoteQueryStep,
2626
} from "../../common"
27+
import { acquireLockStep } from "../../locking/acquire-lock"
28+
import { releaseLockStep } from "../../locking/release-lock"
2729
import { addOrderTransactionStep } from "../../order/steps/add-order-transaction"
2830
import { createOrdersStep } from "../../order/steps/create-orders"
2931
import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session"
@@ -60,7 +62,9 @@ export type CompleteCartWorkflowOutput = {
6062
id: string
6163
}
6264

63-
export const THREE_DAYS = 60 * 60 * 24 * 3
65+
const THREE_DAYS = 60 * 60 * 24 * 3
66+
const THIRTY_SECONDS = 30
67+
const TWO_MINUTES = 60 * 2
6468

6569
export const completeCartWorkflowId = "complete-cart"
6670
/**
@@ -93,6 +97,12 @@ export const completeCartWorkflow = createWorkflow(
9397
retentionTime: THREE_DAYS,
9498
},
9599
(input: WorkflowData<CompleteCartWorkflowInput>) => {
100+
acquireLockStep({
101+
key: input.id,
102+
timeout: THIRTY_SECONDS,
103+
ttl: TWO_MINUTES,
104+
})
105+
96106
const orderCart = useQueryGraphStep({
97107
entity: "order_cart",
98108
fields: ["cart_id", "order_id"],
@@ -381,6 +391,10 @@ export const completeCartWorkflow = createWorkflow(
381391
return createdOrder
382392
})
383393

394+
releaseLockStep({
395+
key: input.id,
396+
})
397+
384398
const result = transform({ order, orderId }, ({ order, orderId }) => {
385399
return { id: order?.id ?? orderId } as CompleteCartWorkflowOutput
386400
})

packages/core/core-flows/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from "./fulfillment"
1111
export * from "./inventory"
1212
export * from "./invite"
1313
export * from "./line-item"
14+
export * from "./locking"
1415
export * from "./notification"
1516
export * from "./order"
1617
export * from "./payment"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { isDefined, Modules } from "@medusajs/framework/utils"
2+
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
3+
import { setTimeout } from "timers/promises"
4+
5+
/**
6+
* The keys to be locked
7+
*/
8+
export interface AcquireLockStepInput {
9+
key: string | string[]
10+
timeout?: number // in seconds. Defaults to 0
11+
retryInterval?: number // in seconds. Defaults to 0.3
12+
ttl?: number // in seconds
13+
ownerId?: string
14+
provider?: string
15+
}
16+
17+
export const acquireLockStepId = "acquire-lock-step"
18+
/**
19+
* This step acquires a lock for a given key.
20+
*
21+
* @example
22+
* const data = acquireLockStep({
23+
* "key": "my-lock-key",
24+
* "ttl": 60
25+
* })
26+
*/
27+
export const acquireLockStep = createStep(
28+
acquireLockStepId,
29+
async (data: AcquireLockStepInput, { container }) => {
30+
const keys = Array.isArray(data.key)
31+
? data.key
32+
: isDefined(data.key)
33+
? [data.key]
34+
: []
35+
36+
if (!keys.length) {
37+
return new StepResponse(void 0)
38+
}
39+
40+
const locking = container.resolve(Modules.LOCKING)
41+
42+
const retryInterval = data.retryInterval ?? 0.3
43+
const tryUntil = Date.now() + (data.timeout ?? 0) * 1000
44+
45+
while (true) {
46+
try {
47+
await locking.acquire(data.key, {
48+
expire: data.ttl,
49+
ownerId: data.ownerId,
50+
provider: data.provider,
51+
})
52+
break
53+
} catch (e) {
54+
if (Date.now() >= tryUntil) {
55+
throw e
56+
}
57+
}
58+
59+
await setTimeout(retryInterval * 1000)
60+
}
61+
62+
return new StepResponse(void 0, {
63+
keys,
64+
ownerId: data.ownerId,
65+
provider: data.provider,
66+
})
67+
},
68+
async (data, { container }) => {
69+
if (!data?.keys?.length) {
70+
return
71+
}
72+
73+
const locking = container.resolve(Modules.LOCKING)
74+
75+
await locking.release(data.keys, {
76+
ownerId: data.ownerId,
77+
provider: data.provider,
78+
})
79+
80+
return new StepResponse()
81+
}
82+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./acquire-lock"
2+
export * from "./release-lock"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { isDefined, Modules } from "@medusajs/framework/utils"
2+
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
3+
4+
/**
5+
* The locked keys to be released
6+
*/
7+
export interface ReleaseLockStepInput {
8+
key: string | string[]
9+
ownerId?: string
10+
provider?: string
11+
}
12+
13+
export const releaseLockStepId = "release-lock-step"
14+
/**
15+
* This step releases a lock for a given key.
16+
*
17+
* @example
18+
* const data = releaseLockStep({
19+
* "key": "my-lock-key"
20+
* })
21+
*/
22+
export const releaseLockStep = createStep(
23+
releaseLockStepId,
24+
async (data: ReleaseLockStepInput, { container }) => {
25+
const keys = Array.isArray(data.key)
26+
? data.key
27+
: isDefined(data.key)
28+
? [data.key]
29+
: []
30+
31+
if (!keys.length) {
32+
return new StepResponse(true)
33+
}
34+
35+
const locking = container.resolve(Modules.LOCKING)
36+
const released = await locking.release(keys, {
37+
ownerId: data.ownerId,
38+
provider: data.provider,
39+
})
40+
41+
return new StepResponse(released)
42+
}
43+
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Modules } from "@medusajs/framework/utils"
2+
import { createStep } from "@medusajs/framework/workflows-sdk"
3+
import { completeCartWorkflowId } from "../../cart/workflows/complete-cart"
4+
5+
/**
6+
* The data to complete a cart after a payment is captured.
7+
*/
8+
export type CompleteCartAfterPaymentStepInput = {
9+
/**
10+
* The ID of the cart to complete.
11+
*/
12+
cart_id: string
13+
}
14+
15+
export const completeCartAfterPaymentStepId = "complete-cart-after-payment-step"
16+
/**
17+
* This step completes a cart after a payment is captured.
18+
*/
19+
export const completeCartAfterPaymentStep = createStep(
20+
completeCartAfterPaymentStepId,
21+
async (input: CompleteCartAfterPaymentStepInput, { container }) => {
22+
const workflowEngine = container.resolve(Modules.WORKFLOW_ENGINE)
23+
24+
await workflowEngine.run(completeCartWorkflowId, {
25+
input: {
26+
id: input.cart_id,
27+
},
28+
transactionId: input.cart_id,
29+
})
30+
}
31+
)

0 commit comments

Comments
 (0)