fix: Idempotent cart completion (#9231)
What - Store result of cart-completion workflow for three days by default - This enables the built-in idempotency mechanism to kick-in, provided the same transaction ID is used on workflow executions - Return order from cart-completion workflow if the cart has already been completed - In case transaction ID is not used on workflow executions, we still only want to complete a cart once
This commit is contained in:
@@ -34,6 +34,7 @@ export const validateCartPaymentsStep = createStep(
|
||||
const processablePaymentStatuses = [
|
||||
PaymentSessionStatus.PENDING,
|
||||
PaymentSessionStatus.REQUIRES_MORE,
|
||||
PaymentSessionStatus.AUTHORIZED, // E.g. payment was authorized, but the cart was not completed
|
||||
]
|
||||
|
||||
const paymentsToProcess = paymentCollection.payment_sessions?.filter((ps) =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
CartWorkflowDTO,
|
||||
OrderDTO,
|
||||
UsageComputedActions,
|
||||
UsageComputedActions
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
Modules,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
createWorkflow,
|
||||
parallelize,
|
||||
transform,
|
||||
when,
|
||||
WorkflowData,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
@@ -20,12 +20,12 @@ import {
|
||||
emitEventStep,
|
||||
useRemoteQueryStep,
|
||||
} from "../../common"
|
||||
import { useQueryStep } from "../../common/steps/use-query"
|
||||
import { createOrdersStep } from "../../order/steps/create-orders"
|
||||
import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session"
|
||||
import { registerUsageStep } from "../../promotion/steps/register-usage"
|
||||
import { updateCartsStep, validateCartPaymentsStep } from "../steps"
|
||||
import { reserveInventoryStep } from "../steps/reserve-inventory"
|
||||
import { validateCartStep } from "../steps/validate-cart"
|
||||
import { completeCartFields } from "../utils/fields"
|
||||
import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input"
|
||||
import {
|
||||
@@ -38,36 +38,56 @@ export type CompleteCartWorkflowInput = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const THREE_DAYS = 60 * 60 * 24 * 3
|
||||
|
||||
export const completeCartWorkflowId = "complete-cart"
|
||||
/**
|
||||
* This workflow completes a cart.
|
||||
*/
|
||||
export const completeCartWorkflow = createWorkflow(
|
||||
completeCartWorkflowId,
|
||||
{
|
||||
name: completeCartWorkflowId,
|
||||
store: true,
|
||||
idempotent: true,
|
||||
// 3 days of retention time
|
||||
retentionTime: THREE_DAYS,
|
||||
},
|
||||
(
|
||||
input: WorkflowData<CompleteCartWorkflowInput>
|
||||
): WorkflowResponse<OrderDTO> => {
|
||||
const cart = useRemoteQueryStep({
|
||||
entry_point: "cart",
|
||||
fields: completeCartFields,
|
||||
variables: { id: input.id },
|
||||
list: false,
|
||||
): WorkflowResponse<{ id: string }> => {
|
||||
const orderCart = useQueryStep({
|
||||
entity: "order_cart",
|
||||
fields: ["cart_id", "order_id"],
|
||||
filters: { cart_id: input.id },
|
||||
})
|
||||
|
||||
validateCartStep({ cart })
|
||||
|
||||
const paymentSessions = validateCartPaymentsStep({ cart })
|
||||
|
||||
authorizePaymentSessionStep({
|
||||
// We choose the first payment session, as there will only be one active payment session
|
||||
// This might change in the future.
|
||||
id: paymentSessions[0].id,
|
||||
context: { cart_id: cart.id },
|
||||
const orderId = transform({ orderCart }, ({ orderCart }) => {
|
||||
return orderCart.data[0]?.order_id
|
||||
})
|
||||
|
||||
const { variants, sales_channel_id } = transform({ cart }, (data) => {
|
||||
const allItems: any[] = []
|
||||
const allVariants: any[] = []
|
||||
// If order ID does not exist, we are completing the cart for the first time
|
||||
const order = when({ orderId }, ({ orderId }) => {
|
||||
return !orderId
|
||||
}).then(() => {
|
||||
const cart = useRemoteQueryStep({
|
||||
entry_point: "cart",
|
||||
fields: completeCartFields,
|
||||
variables: { id: input.id },
|
||||
list: false,
|
||||
})
|
||||
|
||||
const paymentSessions = validateCartPaymentsStep({ cart })
|
||||
|
||||
authorizePaymentSessionStep({
|
||||
// We choose the first payment session, as there will only be one active payment session
|
||||
// This might change in the future.
|
||||
id: paymentSessions[0].id,
|
||||
context: { cart_id: cart.id },
|
||||
})
|
||||
|
||||
const { variants, sales_channel_id } = transform({ cart }, (data) => {
|
||||
const allItems: any[] = []
|
||||
const allVariants: any[] = []
|
||||
|
||||
data.cart?.items?.forEach((item) => {
|
||||
allItems.push({
|
||||
@@ -78,163 +98,164 @@ export const completeCartWorkflow = createWorkflow(
|
||||
allVariants.push(item.variant)
|
||||
})
|
||||
|
||||
return {
|
||||
variants: allVariants,
|
||||
items: allItems,
|
||||
sales_channel_id: data.cart.sales_channel_id,
|
||||
}
|
||||
})
|
||||
|
||||
const finalCart = useRemoteQueryStep({
|
||||
entry_point: "cart",
|
||||
fields: completeCartFields,
|
||||
variables: { id: input.id },
|
||||
list: false,
|
||||
}).config({ name: "final-cart" })
|
||||
|
||||
const cartToOrder = transform({ cart }, ({ cart }) => {
|
||||
const allItems = (cart.items ?? []).map((item) => {
|
||||
return prepareLineItemData({
|
||||
item,
|
||||
variant: item.variant,
|
||||
unitPrice: item.raw_unit_price ?? item.unit_price,
|
||||
isTaxInclusive: item.is_tax_inclusive,
|
||||
quantity: item.raw_quantity ?? item.quantity,
|
||||
metadata: item?.metadata,
|
||||
taxLines: item.tax_lines ?? [],
|
||||
adjustments: item.adjustments ?? [],
|
||||
})
|
||||
})
|
||||
|
||||
const shippingMethods = (cart.shipping_methods ?? []).map((sm) => {
|
||||
return {
|
||||
name: sm.name,
|
||||
description: sm.description,
|
||||
amount: sm.raw_amount ?? sm.amount,
|
||||
is_tax_inclusive: sm.is_tax_inclusive,
|
||||
shipping_option_id: sm.shipping_option_id,
|
||||
data: sm.data,
|
||||
metadata: sm.metadata,
|
||||
tax_lines: prepareTaxLinesData(sm.tax_lines ?? []),
|
||||
adjustments: prepareAdjustmentsData(sm.adjustments ?? []),
|
||||
variants: allVariants,
|
||||
items: allItems,
|
||||
sales_channel_id: data.cart.sales_channel_id,
|
||||
}
|
||||
})
|
||||
|
||||
const itemAdjustments = allItems
|
||||
.map((item) => item.adjustments ?? [])
|
||||
.flat(1)
|
||||
const shippingAdjustments = shippingMethods
|
||||
.map((sm) => sm.adjustments ?? [])
|
||||
.flat(1)
|
||||
const cartToOrder = transform({ cart }, ({ cart }) => {
|
||||
const allItems = (cart.items ?? []).map((item) => {
|
||||
return prepareLineItemData({
|
||||
item,
|
||||
variant: item.variant,
|
||||
unitPrice: item.raw_unit_price ?? item.unit_price,
|
||||
isTaxInclusive: item.is_tax_inclusive,
|
||||
quantity: item.raw_quantity ?? item.quantity,
|
||||
metadata: item?.metadata,
|
||||
taxLines: item.tax_lines ?? [],
|
||||
adjustments: item.adjustments ?? [],
|
||||
})
|
||||
})
|
||||
|
||||
const promoCodes = [...itemAdjustments, ...shippingAdjustments]
|
||||
.map((adjustment) => adjustment.code)
|
||||
.filter((code) => Boolean) as string[]
|
||||
const shippingMethods = (cart.shipping_methods ?? []).map((sm) => {
|
||||
return {
|
||||
name: sm.name,
|
||||
description: sm.description,
|
||||
amount: sm.raw_amount ?? sm.amount,
|
||||
is_tax_inclusive: sm.is_tax_inclusive,
|
||||
shipping_option_id: sm.shipping_option_id,
|
||||
data: sm.data,
|
||||
metadata: sm.metadata,
|
||||
tax_lines: prepareTaxLinesData(sm.tax_lines ?? []),
|
||||
adjustments: prepareAdjustmentsData(sm.adjustments ?? []),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
region_id: cart.region?.id,
|
||||
customer_id: cart.customer?.id,
|
||||
sales_channel_id: cart.sales_channel_id,
|
||||
status: OrderStatus.PENDING,
|
||||
email: cart.email,
|
||||
currency_code: cart.currency_code,
|
||||
shipping_address: cart.shipping_address,
|
||||
billing_address: cart.billing_address,
|
||||
no_notification: false,
|
||||
items: allItems,
|
||||
shipping_methods: shippingMethods,
|
||||
metadata: cart.metadata,
|
||||
promo_codes: promoCodes,
|
||||
}
|
||||
})
|
||||
const itemAdjustments = allItems
|
||||
.map((item) => item.adjustments ?? [])
|
||||
.flat(1)
|
||||
const shippingAdjustments = shippingMethods
|
||||
.map((sm) => sm.adjustments ?? [])
|
||||
.flat(1)
|
||||
|
||||
const createdOrders = createOrdersStep([cartToOrder])
|
||||
const promoCodes = [...itemAdjustments, ...shippingAdjustments]
|
||||
.map((adjustment) => adjustment.code)
|
||||
.filter(Boolean)
|
||||
|
||||
const order = transform(
|
||||
{ createdOrders },
|
||||
({ createdOrders }) => createdOrders[0]
|
||||
)
|
||||
return {
|
||||
region_id: cart.region?.id,
|
||||
customer_id: cart.customer?.id,
|
||||
sales_channel_id: cart.sales_channel_id,
|
||||
status: OrderStatus.PENDING,
|
||||
email: cart.email,
|
||||
currency_code: cart.currency_code,
|
||||
shipping_address: cart.shipping_address,
|
||||
billing_address: cart.billing_address,
|
||||
no_notification: false,
|
||||
items: allItems,
|
||||
shipping_methods: shippingMethods,
|
||||
metadata: cart.metadata,
|
||||
promo_codes: promoCodes,
|
||||
}
|
||||
})
|
||||
|
||||
const reservationItemsData = transform({ order }, ({ order }) =>
|
||||
order.items!.map((i) => ({
|
||||
variant_id: i.variant_id,
|
||||
quantity: i.quantity,
|
||||
id: i.id,
|
||||
}))
|
||||
)
|
||||
const createdOrders = createOrdersStep([cartToOrder])
|
||||
|
||||
const formatedInventoryItems = transform(
|
||||
{
|
||||
input: {
|
||||
sales_channel_id,
|
||||
variants,
|
||||
items: reservationItemsData,
|
||||
},
|
||||
},
|
||||
prepareConfirmInventoryInput
|
||||
)
|
||||
const createdOrder = transform({ createdOrders }, ({ createdOrders }) => {
|
||||
return createdOrders?.[0] ?? undefined
|
||||
})
|
||||
|
||||
const updateCompletedAt = transform({ cart }, ({ cart }) => {
|
||||
return {
|
||||
id: cart.id,
|
||||
completed_at: new Date(),
|
||||
}
|
||||
})
|
||||
const reservationItemsData = transform(
|
||||
{ createdOrder },
|
||||
({ createdOrder }) =>
|
||||
createdOrder.items!.map((i) => ({
|
||||
variant_id: i.variant_id,
|
||||
quantity: i.quantity,
|
||||
id: i.id,
|
||||
}))
|
||||
)
|
||||
|
||||
parallelize(
|
||||
createRemoteLinkStep([
|
||||
const formatedInventoryItems = transform(
|
||||
{
|
||||
[Modules.ORDER]: { order_id: order.id },
|
||||
[Modules.CART]: { cart_id: finalCart.id },
|
||||
},
|
||||
{
|
||||
[Modules.ORDER]: { order_id: order.id },
|
||||
[Modules.PAYMENT]: {
|
||||
payment_collection_id: cart.payment_collection.id,
|
||||
input: {
|
||||
sales_channel_id,
|
||||
variants,
|
||||
items: reservationItemsData,
|
||||
},
|
||||
},
|
||||
]),
|
||||
updateCartsStep([updateCompletedAt]),
|
||||
reserveInventoryStep(formatedInventoryItems),
|
||||
emitEventStep({
|
||||
eventName: OrderWorkflowEvents.PLACED,
|
||||
data: { id: order.id },
|
||||
prepareConfirmInventoryInput
|
||||
)
|
||||
|
||||
const updateCompletedAt = transform({ cart }, ({ cart }) => {
|
||||
return {
|
||||
id: cart.id,
|
||||
completed_at: new Date(),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const promotionUsage = transform(
|
||||
{ cart },
|
||||
({ cart }: { cart: CartWorkflowDTO }) => {
|
||||
const promotionUsage: UsageComputedActions[] = []
|
||||
parallelize(
|
||||
createRemoteLinkStep([
|
||||
{
|
||||
[Modules.ORDER]: { order_id: createdOrder.id },
|
||||
[Modules.CART]: { cart_id: cart.id },
|
||||
},
|
||||
{
|
||||
[Modules.ORDER]: { order_id: createdOrder.id },
|
||||
[Modules.PAYMENT]: {
|
||||
payment_collection_id: cart.payment_collection.id,
|
||||
},
|
||||
},
|
||||
]),
|
||||
updateCartsStep([updateCompletedAt]),
|
||||
reserveInventoryStep(formatedInventoryItems),
|
||||
emitEventStep({
|
||||
eventName: OrderWorkflowEvents.PLACED,
|
||||
data: { id: createdOrder.id },
|
||||
})
|
||||
)
|
||||
|
||||
const itemAdjustments = (cart.items ?? [])
|
||||
.map((item) => item.adjustments ?? [])
|
||||
.flat(1)
|
||||
const promotionUsage = transform(
|
||||
{ cart },
|
||||
({ cart }: { cart: CartWorkflowDTO }) => {
|
||||
const promotionUsage: UsageComputedActions[] = []
|
||||
|
||||
const shippingAdjustments = (cart.shipping_methods ?? [])
|
||||
.map((item) => item.adjustments ?? [])
|
||||
.flat(1)
|
||||
const itemAdjustments = (cart.items ?? [])
|
||||
.map((item) => item.adjustments ?? [])
|
||||
.flat(1)
|
||||
|
||||
for (const adjustment of itemAdjustments) {
|
||||
promotionUsage.push({
|
||||
amount: adjustment.amount,
|
||||
code: adjustment.code!,
|
||||
})
|
||||
const shippingAdjustments = (cart.shipping_methods ?? [])
|
||||
.map((item) => item.adjustments ?? [])
|
||||
.flat(1)
|
||||
|
||||
for (const adjustment of itemAdjustments) {
|
||||
promotionUsage.push({
|
||||
amount: adjustment.amount,
|
||||
code: adjustment.code!,
|
||||
})
|
||||
}
|
||||
|
||||
for (const adjustment of shippingAdjustments) {
|
||||
promotionUsage.push({
|
||||
amount: adjustment.amount,
|
||||
code: adjustment.code!,
|
||||
})
|
||||
}
|
||||
|
||||
return promotionUsage
|
||||
}
|
||||
)
|
||||
|
||||
for (const adjustment of shippingAdjustments) {
|
||||
promotionUsage.push({
|
||||
amount: adjustment.amount,
|
||||
code: adjustment.code!,
|
||||
})
|
||||
}
|
||||
registerUsageStep(promotionUsage)
|
||||
|
||||
return promotionUsage
|
||||
}
|
||||
)
|
||||
return createdOrder
|
||||
})
|
||||
|
||||
registerUsageStep(promotionUsage)
|
||||
const result = transform({ order, orderId }, ({ order, orderId }) => {
|
||||
return { id: order?.id ?? orderId }
|
||||
})
|
||||
|
||||
return new WorkflowResponse(order)
|
||||
return new WorkflowResponse(result)
|
||||
}
|
||||
)
|
||||
|
||||
21
packages/core/core-flows/src/common/steps/use-query.ts
Normal file
21
packages/core/core-flows/src/common/steps/use-query.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ContainerRegistrationKeys } from "@medusajs/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
|
||||
|
||||
interface QueryInput {
|
||||
entity: string
|
||||
fields: string[]
|
||||
filters?: Record<string, unknown>
|
||||
context?: any
|
||||
}
|
||||
|
||||
export const useQueryStepId = "use-query"
|
||||
export const useQueryStep = createStep(
|
||||
useQueryStepId,
|
||||
async (data: QueryInput, { container }) => {
|
||||
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||
|
||||
const result = await query.graph(data)
|
||||
|
||||
return new StepResponse(result)
|
||||
}
|
||||
)
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./capture-payment"
|
||||
export * from "./on-payment-processed"
|
||||
export * from "./process-payment"
|
||||
export * from "./refund-payment"
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { WebhookActionResult } from "@medusajs/types"
|
||||
import { createWorkflow, when } from "@medusajs/workflows-sdk"
|
||||
import { completeCartWorkflow } from "../../cart"
|
||||
import { useRemoteQueryStep } from "../../common"
|
||||
import { useQueryStep } from "../../common/steps/use-query"
|
||||
|
||||
export const onPaymentProcessedWorkflowId = "on-payment-processed-workflow"
|
||||
export const onPaymentProcessedWorkflow = createWorkflow(
|
||||
onPaymentProcessedWorkflowId,
|
||||
(input: WebhookActionResult) => {
|
||||
const paymentSessionResult = useRemoteQueryStep({
|
||||
entry_point: "payment_session",
|
||||
fields: ["payment_collection_id"],
|
||||
variables: { filters: { id: input.data?.session_id } },
|
||||
list: false,
|
||||
})
|
||||
|
||||
const cartPaymentCollection = useQueryStep({
|
||||
entity: "cart_payment_collection",
|
||||
fields: ["cart_id"],
|
||||
filters: {
|
||||
payment_collection_id: paymentSessionResult.payment_collection_id,
|
||||
},
|
||||
})
|
||||
|
||||
when({ cartPaymentCollection }, ({ cartPaymentCollection }) => {
|
||||
return !!cartPaymentCollection.data.length
|
||||
}).then(() => {
|
||||
completeCartWorkflow.runAsStep({
|
||||
input: {
|
||||
id: cartPaymentCollection.data[0].cart_id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Add more cases down the line, e.g. order payments
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import { WebhookActionResult } from "@medusajs/types"
|
||||
import { PaymentActions } from "@medusajs/utils"
|
||||
import { createWorkflow, when } from "@medusajs/workflows-sdk"
|
||||
import { useQueryStep } from "../../common/steps/use-query"
|
||||
import { authorizePaymentSessionStep } from "../steps"
|
||||
import { capturePaymentWorkflow } from "./capture-payment"
|
||||
|
||||
interface ProcessPaymentWorkflowInput extends WebhookActionResult {}
|
||||
|
||||
export const processPaymentWorkflowId = "process-payment-workflow"
|
||||
export const processPaymentWorkflow = createWorkflow(
|
||||
processPaymentWorkflowId,
|
||||
(input: ProcessPaymentWorkflowInput) => {
|
||||
const paymentData = useQueryStep({
|
||||
entity: "payment",
|
||||
fields: ["id"],
|
||||
filters: { payment_session_id: input.data?.session_id },
|
||||
})
|
||||
|
||||
when({ input }, ({ input }) => {
|
||||
return (
|
||||
input.action === PaymentActions.SUCCESSFUL && !!paymentData.data.length
|
||||
)
|
||||
}).then(() => {
|
||||
capturePaymentWorkflow.runAsStep({
|
||||
input: {
|
||||
payment_id: paymentData.data[0].id,
|
||||
amount: input.data?.amount,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
when({ input }, ({ input }) => {
|
||||
return (
|
||||
input.action === PaymentActions.AUTHORIZED && !!input.data?.session_id
|
||||
)
|
||||
}).then(() => {
|
||||
authorizePaymentSessionStep({
|
||||
id: input.data!.session_id,
|
||||
context: {},
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user