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
This commit is contained in:
Oli Juhl
2025-10-13 22:09:46 +02:00
committed by GitHub
parent 723dc082f0
commit 1d2b4566fd
5 changed files with 195 additions and 38 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/core-flows": patch
---
chore: Change order of operations in refundPaymentWorkflow

View File

@@ -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",
}),
])
})
})
},
})

View File

@@ -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
}>

View File

@@ -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"

View File

@@ -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 },