diff --git a/.changeset/flat-keys-shave.md b/.changeset/flat-keys-shave.md new file mode 100644 index 0000000000..92a5c6bfd1 --- /dev/null +++ b/.changeset/flat-keys-shave.md @@ -0,0 +1,6 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/types": patch +--- + +fix(core-flows): cancel/refund payment on cart complete error diff --git a/packages/core/core-flows/src/cart/steps/compensate-payment-if-needed.ts b/packages/core/core-flows/src/cart/steps/compensate-payment-if-needed.ts new file mode 100644 index 0000000000..1c5edc85c2 --- /dev/null +++ b/packages/core/core-flows/src/cart/steps/compensate-payment-if-needed.ts @@ -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(ContainerRegistrationKeys.LOGGER) + const paymentModule = container.resolve( + 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}` + ) + } + } + } +) diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index f0b69fde68..bc1bc1beb6 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -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) => { diff --git a/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts b/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts index b621f2d1d3..fc69ba04c8 100644 --- a/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts +++ b/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts @@ -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 }) => { diff --git a/packages/core/core-flows/src/payment/workflows/process-payment.ts b/packages/core/core-flows/src/payment/workflows/process-payment.ts index f332729074..4926dd84f6 100644 --- a/packages/core/core-flows/src/payment/workflows/process-payment.ts +++ b/packages/core/core-flows/src/payment/workflows/process-payment.ts @@ -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 + }) + }) } ) diff --git a/packages/core/types/src/payment/service.ts b/packages/core/types/src/payment/service.ts index ebbdb88270..c4e2922ea6 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -449,6 +449,12 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise + retrievePayment( + paymentId: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + /* ********** PAYMENT SESSION ********** */ /**