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:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/core-flows": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
chore: Change order of operations in refundPaymentWorkflow
|
||||||
@@ -254,7 +254,7 @@ medusaIntegrationTestRunner({
|
|||||||
refund_reason_id: refundReason.id,
|
refund_reason_id: refundReason.id,
|
||||||
refund_reason: expect.objectContaining({
|
refund_reason: expect.objectContaining({
|
||||||
label: "test",
|
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",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+13
-4
@@ -1,6 +1,15 @@
|
|||||||
import type { BigNumberInput, OrderDTO } from "@medusajs/framework/types"
|
import type { BigNumberInput, OrderDTO } from "@medusajs/framework/types"
|
||||||
import { ChangeActionType, OrderChangeStatus, OrderChangeType, } from "@medusajs/framework/utils"
|
import {
|
||||||
import { createStep, createWorkflow, transform, WorkflowData, } from "@medusajs/framework/workflows-sdk"
|
ChangeActionType,
|
||||||
|
OrderChangeStatus,
|
||||||
|
OrderChangeType,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
transform,
|
||||||
|
WorkflowData,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
import { useQueryGraphStep } from "../../../common"
|
import { useQueryGraphStep } from "../../../common"
|
||||||
import { confirmOrderChanges } from "../../steps/confirm-order-changes"
|
import { confirmOrderChanges } from "../../steps/confirm-order-changes"
|
||||||
import { createOrderChangeStep } from "../../steps/create-order-change"
|
import { createOrderChangeStep } from "../../steps/create-order-change"
|
||||||
@@ -27,8 +36,8 @@ export const createOrderRefundCreditLinesWorkflow = createWorkflow(
|
|||||||
function (
|
function (
|
||||||
input: WorkflowData<{
|
input: WorkflowData<{
|
||||||
order_id: string
|
order_id: string
|
||||||
amount: BigNumberInput,
|
amount: BigNumberInput
|
||||||
reference?: string,
|
reference?: string
|
||||||
referenceId?: string
|
referenceId?: string
|
||||||
created_by?: string
|
created_by?: string
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -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 { Modules } from "@medusajs/framework/utils"
|
||||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { BigNumberInput } from "@medusajs/framework/types"
|
import { BigNumberInput, PaymentDTO } from "@medusajs/framework/types"
|
||||||
import { MathBN, PaymentEvents } from "@medusajs/framework/utils"
|
import {
|
||||||
import { createWorkflow, transform, when, WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk"
|
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 { emitEventStep, useRemoteQueryStep } from "../../common"
|
||||||
import { addOrderTransactionStep } from "../../order/steps/add-order-transaction"
|
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 { createOrderRefundCreditLinesWorkflow } from "../../order/workflows/payments/create-order-refund-credit-lines"
|
||||||
|
import { refundPaymentStep } from "../steps/refund-payment"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The data to refund a payment.
|
* The data to refund a payment.
|
||||||
@@ -32,6 +44,49 @@ export type RefundPaymentWorkflowInput = {
|
|||||||
refund_reason_id?: string
|
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"
|
export const refundPaymentWorkflowId = "refund-payment-workflow"
|
||||||
/**
|
/**
|
||||||
* This workflow refunds a payment. It's used by the
|
* This workflow refunds a payment. It's used by the
|
||||||
@@ -63,12 +118,21 @@ export const refundPaymentWorkflow = createWorkflow(
|
|||||||
"currency_code",
|
"currency_code",
|
||||||
"amount",
|
"amount",
|
||||||
"raw_amount",
|
"raw_amount",
|
||||||
|
"captures.raw_amount",
|
||||||
|
"refunds.raw_amount",
|
||||||
],
|
],
|
||||||
variables: { id: input.payment_id },
|
variables: { id: input.payment_id },
|
||||||
list: false,
|
list: false,
|
||||||
throw_if_key_not_found: true,
|
throw_if_key_not_found: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
when({ input }, ({ input }) => !!input.amount).then(() =>
|
||||||
|
validateRefundPaymentExceedsCapturedAmountStep({
|
||||||
|
payment,
|
||||||
|
refundAmount: input.amount as BigNumberInput,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const orderPaymentCollection = useRemoteQueryStep({
|
const orderPaymentCollection = useRemoteQueryStep({
|
||||||
entry_point: "order_payment_collection",
|
entry_point: "order_payment_collection",
|
||||||
fields: ["order.id"],
|
fields: ["order.id"],
|
||||||
@@ -87,8 +151,8 @@ export const refundPaymentWorkflow = createWorkflow(
|
|||||||
|
|
||||||
const refundReason = when(
|
const refundReason = when(
|
||||||
"fetch-refund-reason",
|
"fetch-refund-reason",
|
||||||
{ input }, ({ input }) =>
|
{ input },
|
||||||
!!input.refund_reason_id
|
({ input }) => !!input.refund_reason_id
|
||||||
).then(() => {
|
).then(() => {
|
||||||
return useRemoteQueryStep({
|
return useRemoteQueryStep({
|
||||||
entry_point: "refund_reason",
|
entry_point: "refund_reason",
|
||||||
@@ -99,34 +163,30 @@ export const refundPaymentWorkflow = createWorkflow(
|
|||||||
}).config({ name: "refund-reason" })
|
}).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 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 }) => {
|
when({ orderPaymentCollection }, ({ orderPaymentCollection }) => {
|
||||||
return !!orderPaymentCollection?.order?.id
|
return !!orderPaymentCollection?.order?.id
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
@@ -151,6 +211,19 @@ export const refundPaymentWorkflow = createWorkflow(
|
|||||||
addOrderTransactionStep(orderTransactionData)
|
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({
|
emitEventStep({
|
||||||
eventName: PaymentEvents.REFUNDED,
|
eventName: PaymentEvents.REFUNDED,
|
||||||
data: { id: payment.id },
|
data: { id: payment.id },
|
||||||
|
|||||||
Reference in New Issue
Block a user