From 1d2b4566fda0bdd826c3d1fe090b1ee3efdbd9bd Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:09:46 +0200 Subject: [PATCH] chore: Ensure refund doesn't exceed captured amount (#13744) * wip * chore: prepare for PR * move to end * Change order of operations in refundPaymentWorkflow Updated the order of operations in the refundPaymentWorkflow. * chore: Add validation --- .changeset/strange-worms-invent.md | 5 + .../__tests__/payment/admin/payment.spec.ts | 69 ++++++++- .../create-order-refund-credit-lines.ts | 17 ++- .../src/payment/steps/refund-payment.ts | 5 +- .../src/payment/workflows/refund-payment.ts | 137 ++++++++++++++---- 5 files changed, 195 insertions(+), 38 deletions(-) create mode 100644 .changeset/strange-worms-invent.md diff --git a/.changeset/strange-worms-invent.md b/.changeset/strange-worms-invent.md new file mode 100644 index 0000000000..48ef486802 --- /dev/null +++ b/.changeset/strange-worms-invent.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +chore: Change order of operations in refundPaymentWorkflow diff --git a/integration-tests/http/__tests__/payment/admin/payment.spec.ts b/integration-tests/http/__tests__/payment/admin/payment.spec.ts index af3b9270f0..f825f5eca9 100644 --- a/integration-tests/http/__tests__/payment/admin/payment.spec.ts +++ b/integration-tests/http/__tests__/payment/admin/payment.spec.ts @@ -254,7 +254,7 @@ medusaIntegrationTestRunner({ refund_reason_id: refundReason.id, refund_reason: expect.objectContaining({ label: "test", - code: "test" + code: "test", }), }), ], @@ -369,6 +369,73 @@ medusaIntegrationTestRunner({ }), ]) }) + + it("should throw if the refund amount exceeds the payment amount", async () => { + const payment = order.payment_collections[0].payments[0] + + const refundReason = ( + await api.post( + `/admin/refund-reasons`, + { label: "Test", code: "test" }, + adminHeaders + ) + ).data.refund_reason + + await api.post( + `/admin/payments/${payment.id}/capture`, + undefined, + adminHeaders + ) + + await api.post( + `/admin/payments/${payment.id}/refund`, + { + amount: 50, + refund_reason_id: refundReason.id, + }, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + expect(updatedOrder.credit_line_total).toEqual(50) + expect(updatedOrder.credit_lines).toEqual([ + expect.objectContaining({ + reference: "Test", + reference_id: "test", + }), + ]) + + try { + await api.post( + `/admin/payments/${payment.id}/refund`, + { + amount: 5000, + refund_reason_id: refundReason.id, + }, + adminHeaders + ) + } catch (error) { + expect(error.response.status).toBe(400) + expect(error.response.data.message).toBe( + "You are not allowed to refund more than the captured amount" + ) + } + + const updatedUpdatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + expect(updatedUpdatedOrder.credit_line_total).toEqual(50) + expect(updatedUpdatedOrder.credit_lines).toEqual([ + expect.objectContaining({ + reference: "Test", + reference_id: "test", + }), + ]) + }) }) }, }) diff --git a/packages/core/core-flows/src/order/workflows/payments/create-order-refund-credit-lines.ts b/packages/core/core-flows/src/order/workflows/payments/create-order-refund-credit-lines.ts index 56f200ccfc..74ac133876 100644 --- a/packages/core/core-flows/src/order/workflows/payments/create-order-refund-credit-lines.ts +++ b/packages/core/core-flows/src/order/workflows/payments/create-order-refund-credit-lines.ts @@ -1,6 +1,15 @@ import type { BigNumberInput, OrderDTO } from "@medusajs/framework/types" -import { ChangeActionType, OrderChangeStatus, OrderChangeType, } from "@medusajs/framework/utils" -import { createStep, createWorkflow, transform, WorkflowData, } from "@medusajs/framework/workflows-sdk" +import { + ChangeActionType, + OrderChangeStatus, + OrderChangeType, +} from "@medusajs/framework/utils" +import { + createStep, + createWorkflow, + transform, + WorkflowData, +} from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../../common" import { confirmOrderChanges } from "../../steps/confirm-order-changes" import { createOrderChangeStep } from "../../steps/create-order-change" @@ -27,8 +36,8 @@ export const createOrderRefundCreditLinesWorkflow = createWorkflow( function ( input: WorkflowData<{ order_id: string - amount: BigNumberInput, - reference?: string, + amount: BigNumberInput + reference?: string referenceId?: string created_by?: string }> diff --git a/packages/core/core-flows/src/payment/steps/refund-payment.ts b/packages/core/core-flows/src/payment/steps/refund-payment.ts index 867551e56e..7f7dcda419 100644 --- a/packages/core/core-flows/src/payment/steps/refund-payment.ts +++ b/packages/core/core-flows/src/payment/steps/refund-payment.ts @@ -1,4 +1,7 @@ -import { BigNumberInput, IPaymentModuleService } from "@medusajs/framework/types" +import { + BigNumberInput, + IPaymentModuleService, +} from "@medusajs/framework/types" import { Modules } from "@medusajs/framework/utils" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" diff --git a/packages/core/core-flows/src/payment/workflows/refund-payment.ts b/packages/core/core-flows/src/payment/workflows/refund-payment.ts index 676cc533f7..f50c958173 100644 --- a/packages/core/core-flows/src/payment/workflows/refund-payment.ts +++ b/packages/core/core-flows/src/payment/workflows/refund-payment.ts @@ -1,10 +1,22 @@ -import { BigNumberInput } from "@medusajs/framework/types" -import { MathBN, PaymentEvents } from "@medusajs/framework/utils" -import { createWorkflow, transform, when, WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" +import { BigNumberInput, PaymentDTO } from "@medusajs/framework/types" +import { + BigNumber, + MathBN, + MedusaError, + PaymentEvents, +} from "@medusajs/framework/utils" +import { + createStep, + createWorkflow, + transform, + when, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" import { emitEventStep, useRemoteQueryStep } from "../../common" import { addOrderTransactionStep } from "../../order/steps/add-order-transaction" -import { refundPaymentStep } from "../steps/refund-payment" import { createOrderRefundCreditLinesWorkflow } from "../../order/workflows/payments/create-order-refund-credit-lines" +import { refundPaymentStep } from "../steps/refund-payment" /** * The data to refund a payment. @@ -32,6 +44,49 @@ export type RefundPaymentWorkflowInput = { refund_reason_id?: string } +/** + * This step validates that an order refund credit line can be issued + */ +export const validateRefundPaymentExceedsCapturedAmountStep = createStep( + "validate-refund-payment-exceeds-captured-amount", + async function ({ + payment, + refundAmount, + }: { + payment: PaymentDTO + refundAmount: BigNumberInput + }) { + const capturedAmount = (payment.captures || []).reduce( + (captureAmount, next) => { + const amountAsBigNumber = new BigNumber( + next.raw_amount as BigNumberInput + ) + return MathBN.add(captureAmount, amountAsBigNumber) + }, + MathBN.convert(0) + ) + + const refundedAmount = (payment.refunds || []).reduce( + (refundedAmount, next) => { + const amountAsBigNumber = new BigNumber( + next.raw_amount as BigNumberInput + ) + return MathBN.add(refundedAmount, amountAsBigNumber) + }, + MathBN.convert(0) + ) + + const totalRefundedAmount = MathBN.add(refundedAmount, refundAmount) + + if (MathBN.lt(capturedAmount, totalRefundedAmount)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You are not allowed to refund more than the captured amount` + ) + } + } +) + export const refundPaymentWorkflowId = "refund-payment-workflow" /** * This workflow refunds a payment. It's used by the @@ -63,12 +118,21 @@ export const refundPaymentWorkflow = createWorkflow( "currency_code", "amount", "raw_amount", + "captures.raw_amount", + "refunds.raw_amount", ], variables: { id: input.payment_id }, list: false, throw_if_key_not_found: true, }) + when({ input }, ({ input }) => !!input.amount).then(() => + validateRefundPaymentExceedsCapturedAmountStep({ + payment, + refundAmount: input.amount as BigNumberInput, + }) + ) + const orderPaymentCollection = useRemoteQueryStep({ entry_point: "order_payment_collection", fields: ["order.id"], @@ -87,8 +151,8 @@ export const refundPaymentWorkflow = createWorkflow( const refundReason = when( "fetch-refund-reason", - { input }, ({ input }) => - !!input.refund_reason_id + { input }, + ({ input }) => !!input.refund_reason_id ).then(() => { return useRemoteQueryStep({ entry_point: "refund_reason", @@ -99,34 +163,30 @@ export const refundPaymentWorkflow = createWorkflow( }).config({ name: "refund-reason" }) }) - const creditLineAmount = transform({ order, payment, input }, ({ order, payment, input }) => { - const pendingDifference = order.summary?.raw_pending_difference! ?? order.summary?.pending_difference! ?? 0 - const amountToRefund = input.amount ?? payment.raw_amount ?? payment.amount - - if (MathBN.lt(pendingDifference, 0)) { - const amountOwed = MathBN.mult(pendingDifference, -1) - - return MathBN.gt(amountToRefund, amountOwed) ? MathBN.sub(amountToRefund, amountOwed) : 0 - } - - return amountToRefund - }) - - when( - { creditLineAmount, refundReason }, ({ creditLineAmount, refundReason }) => MathBN.gt(creditLineAmount, 0) - ).then(() => { - createOrderRefundCreditLinesWorkflow.runAsStep({ - input: { - order_id: order.id, - amount: creditLineAmount, - reference: refundReason?.label, - referenceId: refundReason?.code - }, - }) - }) - const refundPayment = refundPaymentStep(input) + const creditLineAmount = transform( + { order, payment, input }, + ({ order, payment, input }) => { + const pendingDifference = + order.summary?.raw_pending_difference! ?? + order.summary?.pending_difference! ?? + 0 + const amountToRefund = + input.amount ?? payment.raw_amount ?? payment.amount + + if (MathBN.lt(pendingDifference, 0)) { + const amountOwed = MathBN.mult(pendingDifference, -1) + + return MathBN.gt(amountToRefund, amountOwed) + ? MathBN.sub(amountToRefund, amountOwed) + : 0 + } + + return amountToRefund + } + ) + when({ orderPaymentCollection }, ({ orderPaymentCollection }) => { return !!orderPaymentCollection?.order?.id }).then(() => { @@ -151,6 +211,19 @@ export const refundPaymentWorkflow = createWorkflow( addOrderTransactionStep(orderTransactionData) }) + when({ creditLineAmount }, ({ creditLineAmount }) => + MathBN.gt(creditLineAmount, 0) + ).then(() => { + createOrderRefundCreditLinesWorkflow.runAsStep({ + input: { + order_id: order.id, + amount: creditLineAmount, + reference: refundReason?.label, + referenceId: refundReason?.code, + }, + }) + }) + emitEventStep({ eventName: PaymentEvents.REFUNDED, data: { id: payment.id },