feat(core-flows, types): try payment cancelation if cart complete fails in the webhook (#11832)

* fix: try compensating if cart complete fails, reorder process paymnet workflow steps

* fix: condition

* fix: feedback

* feat: changes to the compensation step

* fix: build

* fix: remove payment id as arg

* fix: when input

* chore: remove captures realtion, changeset

* fix: return lates payment object from authorize payment
This commit is contained in:
Frane Polić
2025-03-28 14:49:54 +01:00
committed by GitHub
parent 3dba58785f
commit 95e89a39f3
6 changed files with 130 additions and 23 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/core-flows": patch
"@medusajs/types": patch
---
fix(core-flows): cancel/refund payment on cart complete error

View File

@@ -0,0 +1,80 @@
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import {
ContainerRegistrationKeys,
PaymentSessionStatus,
} from "@medusajs/framework/utils"
import { Modules } from "@medusajs/framework/utils"
import { Logger } from "@medusajs/framework/types"
import { IPaymentModuleService } from "@medusajs/framework/types"
/**
* The payment session's details for compensation.
*/
export interface CompensatePaymentIfNeededStepInput {
/**
* The payment to compensate.
*/
payment_session_id: string
}
export const compensatePaymentIfNeededStepId = "compensate-payment-if-needed"
/**
* Purpose of this step is to be the last compensation in cart completion workflow.
* If the cart completion fails, this step tries to cancel or refund the payment.
*
* @example
* const data = compensatePaymentIfNeededStep({
* payment_session_id: "pay_123"
* })
*/
export const compensatePaymentIfNeededStep = createStep(
compensatePaymentIfNeededStepId,
async (data: CompensatePaymentIfNeededStepInput, { container }) => {
const { payment_session_id } = data
return new StepResponse(payment_session_id)
},
async (paymentSessionId, { container }) => {
if (!paymentSessionId) {
return
}
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
const paymentModule = container.resolve<IPaymentModuleService>(
Modules.PAYMENT
)
const paymentSession = await paymentModule.retrievePaymentSession(
paymentSessionId,
{
relations: ["payment"],
}
)
if (paymentSession.status === PaymentSessionStatus.AUTHORIZED) {
try {
await paymentModule.cancelPayment(paymentSession.id)
} catch (e) {
logger.error(
`Error was thrown trying to cancel payment session - ${paymentSession.id} - ${e}`
)
}
}
if (
paymentSession.status === PaymentSessionStatus.CAPTURED &&
paymentSession.payment?.id
) {
try {
await paymentModule.refundPayment({
payment_id: paymentSession.payment.id,
note: "Refunded due to cart completion failure",
})
} catch (e) {
logger.error(
`Error was thrown trying to refund payment - ${paymentSession.payment?.id} - ${e}`
)
}
}
}
)

View File

@@ -41,7 +41,7 @@ import {
PrepareLineItemDataInput,
prepareTaxLinesData,
} from "../utils/prepare-line-item-data"
import { compensatePaymentIfNeededStep } from "../steps/compensate-payment-if-needed"
/**
* The data to complete a cart and place an order.
*/
@@ -111,6 +111,23 @@ export const completeCartWorkflow = createWorkflow(
name: "cart-query",
})
// this is only run when the cart is completed for the first time (same condition as below)
// but needs to be before the validation step
const paymentSessions = when(
"create-order-payment-compensation",
{ orderId },
({ orderId }) => !orderId
).then(() => {
const paymentSessions = validateCartPaymentsStep({ cart })
// purpose of this step is to run compensation if cart completion fails
// and tries to cancel or refund the payment depending on the status.
compensatePaymentIfNeededStep({
payment_session_id: paymentSessions[0].id,
})
return paymentSessions
})
const validate = createHook("validate", {
input,
cart,
@@ -135,8 +152,6 @@ export const completeCartWorkflow = createWorkflow(
validateShippingStep({ cart, shippingOptions })
const paymentSessions = validateCartPaymentsStep({ cart })
createHook("beforePaymentAuthorization", {
input,
})
@@ -144,7 +159,7 @@ export const completeCartWorkflow = createWorkflow(
const payment = 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,
id: paymentSessions![0].id,
})
const { variants, sales_channel_id } = transform({ cart }, (data) => {

View File

@@ -85,7 +85,7 @@ export const authorizePaymentSessionStep = createStep(
)
}
return new StepResponse(payment)
return new StepResponse(paymentSession.payment)
},
// If payment or any other part of complete cart fails post payment step, we cancel any payments made
async (payment, { container }) => {

View File

@@ -16,10 +16,10 @@ export const processPaymentWorkflowId = "process-payment-workflow"
* This workflow processes a payment to either complete its associated cart,
* capture the payment, or authorize the payment session. It's used when a
* [Webhook Event is received](https://docs.medusajs.com/resources/commerce-modules/payment/webhook-events).
*
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to process a payment in your custom flows based on a webhook action.
*
*
* @example
* const { result } = await processPaymentWorkflow(container)
* .run({
@@ -31,9 +31,9 @@ export const processPaymentWorkflowId = "process-payment-workflow"
* }
* }
* })
*
*
* @summary
*
*
* Process a payment based on a webhook event.
*/
export const processPaymentWorkflow = createWorkflow(
@@ -66,20 +66,6 @@ export const processPaymentWorkflow = createWorkflow(
name: "cart-payment-query",
})
when({ cartPaymentCollection }, ({ cartPaymentCollection }) => {
return !!cartPaymentCollection.data.length
}).then(() => {
completeCartWorkflow
.runAsStep({
input: {
id: cartPaymentCollection.data[0].cart_id,
},
})
.config({
continueOnPermanentFailure: true, // Continue payment processing even if cart completion fails
})
})
when({ input, paymentData }, ({ input, paymentData }) => {
return (
input.action === PaymentActions.SUCCESSFUL && !!paymentData.data.length
@@ -110,5 +96,19 @@ export const processPaymentWorkflow = createWorkflow(
context: {},
})
})
when({ cartPaymentCollection }, ({ cartPaymentCollection }) => {
return !!cartPaymentCollection.data.length
}).then(() => {
completeCartWorkflow
.runAsStep({
input: {
id: cartPaymentCollection.data[0].cart_id,
},
})
.config({
continueOnPermanentFailure: true, // Continue payment processing even if cart completion fails
})
})
}
)

View File

@@ -449,6 +449,12 @@ export interface IPaymentModuleService extends IModuleService {
sharedContext?: Context
): Promise<PaymentSessionDTO>
retrievePayment(
paymentId: string,
config?: FindConfig<PaymentDTO>,
sharedContext?: Context
): Promise<PaymentDTO>
/* ********** PAYMENT SESSION ********** */
/**