feat(core-flows): support ad hoc returns (#13598)

* feat(core-flows): support ad hoc returns

* fix: missing transform

* handle edge case

* refactor

* replace gte for gt

* cleanup

* weird bug fix

* add test

* Create quick-nails-kick.md

* stop sending empty strings

* add code to refund reason

* fix build

* fix tests

* handle code in dashboard

* fix tests

* more tests failing

* add reference and reference id to credit lieng

* rework create refund form
This commit is contained in:
William Bouchard authored and GitHub committed 2025-09-30 07:38:50 -04:00
1 parent bd9ecd5e66
commit 087887fefb
24 files changed
+278 -255

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/payment": patch
"@medusajs/core-flows": patch
---
feat(core-flows): support ad hoc returns
@@ -218,7 +218,7 @@ medusaIntegrationTestRunner({
const refundReason = ( const refundReason = (
await api.post( await api.post(
`/admin/refund-reasons`, `/admin/refund-reasons`,
{ label: "test" }, { label: "test", code: "test" },
adminHeaders adminHeaders
) )
).data.refund_reason ).data.refund_reason
@@ -254,6 +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"
}), }),
}), }),
], ],
@@ -274,7 +275,7 @@ medusaIntegrationTestRunner({
const refundReason = ( const refundReason = (
await api.post( await api.post(
`/admin/refund-reasons`, `/admin/refund-reasons`,
{ label: "test" }, { label: "test", code: "test" },
adminHeaders adminHeaders
) )
).data.refund_reason ).data.refund_reason
@@ -330,57 +331,44 @@ medusaIntegrationTestRunner({
) )
}) })
it("should throw if refund exceeds captured total", async () => { it("should create credit lines if issuing a refund when outstanding amount if >= 0", async () => {
const payment = order.payment_collections[0].payments[0] 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( await api.post(
`/admin/payments/${payment.id}/capture`, `/admin/payments/${payment.id}/capture`,
undefined, undefined,
adminHeaders adminHeaders
) )
await createClaim({ order })
await api.post( await api.post(
`/admin/payments/${payment.id}/refund`, `/admin/payments/${payment.id}/refund`,
{ amount: 25 }, {
amount: 50,
refund_reason_id: refundReason.id,
},
adminHeaders adminHeaders
) )
const e = await api const updatedOrder = (
.post( await api.get(`/admin/orders/${order.id}`, adminHeaders)
`/admin/payments/${payment.id}/refund`, ).data.order
{ amount: 1000 },
adminHeaders
)
.catch((e) => e)
expect(e.response.data.message).toEqual( expect(updatedOrder.credit_line_total).toEqual(50)
"Cannot refund more than pending difference - 75" expect(updatedOrder.credit_lines).toEqual([
) expect.objectContaining({
reference: "Test",
reference_id: "test",
}),
])
}) })
}) })
it("should throw if outstanding amount is not present", async () => {
const payment = order.payment_collections[0].payments[0]
await api.post(
`/admin/payments/${payment.id}/capture`,
undefined,
adminHeaders
)
const e = await api
.post(
`/admin/payments/${payment.id}/refund`,
{ amount: 10 },
adminHeaders
)
.catch((e) => e)
expect(e.response.data.message).toEqual(
"Order does not have an outstanding balance to refund"
)
})
}, },
}) })
@@ -1,5 +1,8 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { adminHeaders, createAdminUser, } from "../../../helpers/create-admin-user" import {
adminHeaders,
createAdminUser,
} from "../../../helpers/create-admin-user"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -15,7 +18,7 @@ medusaIntegrationTestRunner({
refundReason1 = ( refundReason1 = (
await api.post( await api.post(
"/admin/refund-reasons", "/admin/refund-reasons",
{ label: "reason 1 - too big" }, { label: "reason 1 - too big", code: "too_big" },
adminHeaders adminHeaders
) )
).data.refund_reason ).data.refund_reason
@@ -23,7 +26,7 @@ medusaIntegrationTestRunner({
refundReason2 = ( refundReason2 = (
await api.post( await api.post(
"/admin/refund-reasons", "/admin/refund-reasons",
{ label: "reason 2 - too small" }, { label: "reason 2 - too small", code: "too_small" },
adminHeaders adminHeaders
) )
).data.refund_reason ).data.refund_reason
@@ -41,11 +44,11 @@ medusaIntegrationTestRunner({
expect(response.data.count).toEqual(5) // There are 3 default ones expect(response.data.count).toEqual(5) // There are 3 default ones
expect(response.data.refund_reasons).toEqual( expect(response.data.refund_reasons).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ label: "Customer Care Adjustment" }), expect.objectContaining({ label: "Customer Care Adjustment", code: "customer_care_adjustment" }),
expect.objectContaining({ label: "Shipping Issue" }), expect.objectContaining({ label: "Shipping Issue", code: "shipping_issue" }),
expect.objectContaining({ label: "Pricing Error" }), expect.objectContaining({ label: "Pricing Error", code: "pricing_error" }),
expect.objectContaining({ label: "reason 1 - too big" }), expect.objectContaining({ label: "reason 1 - too big", code: "too_big" }),
expect.objectContaining({ label: "reason 2 - too small" }), expect.objectContaining({ label: "reason 2 - too small", code: "too_small" }),
]) ])
) )
}) })
@@ -74,6 +77,7 @@ medusaIntegrationTestRunner({
"/admin/refund-reasons", "/admin/refund-reasons",
{ {
label: "reason test", label: "reason test",
code: "reason_test",
description: "test description", description: "test description",
}, },
adminHeaders adminHeaders
@@ -83,6 +87,7 @@ medusaIntegrationTestRunner({
expect(response.data.refund_reason).toEqual( expect(response.data.refund_reason).toEqual(
expect.objectContaining({ expect.objectContaining({
label: "reason test", label: "reason test",
code: "reason_test",
description: "test description", description: "test description",
}) })
) )
@@ -95,6 +100,7 @@ medusaIntegrationTestRunner({
`/admin/refund-reasons/${refundReason1.id}`, `/admin/refund-reasons/${refundReason1.id}`,
{ {
label: "reason test", label: "reason test",
code: "reason_test",
description: "test description", description: "test description",
}, },
adminHeaders adminHeaders
@@ -104,6 +110,7 @@ medusaIntegrationTestRunner({
expect(response.data.refund_reason).toEqual( expect(response.data.refund_reason).toEqual(
expect.objectContaining({ expect.objectContaining({
label: "reason test", label: "reason test",
code: "reason_test",
description: "test description", description: "test description",
}) })
) )
@@ -18,6 +18,13 @@ export const useRefundReasonTableColumns = () => {
sortAscLabel: t("filters.sorting.alphabeticallyAsc"), sortAscLabel: t("filters.sorting.alphabeticallyAsc"),
sortDescLabel: t("filters.sorting.alphabeticallyDesc"), sortDescLabel: t("filters.sorting.alphabeticallyDesc"),
}), }),
columnHelper.accessor("code", {
header: () => t("fields.code"),
enableSorting: true,
sortLabel: t("fields.code"),
sortAscLabel: t("filters.sorting.alphabeticallyAsc"),
sortDescLabel: t("filters.sorting.alphabeticallyDesc"),
}),
columnHelper.accessor("description", { columnHelper.accessor("description", {
header: () => t("fields.description"), header: () => t("fields.description"),
cell: ({ getValue }) => <DescriptionCell description={getValue()} />, cell: ({ getValue }) => <DescriptionCell description={getValue()} />,
@@ -10306,6 +10306,19 @@
"required": ["label", "placeholder"], "required": ["label", "placeholder"],
"additionalProperties": false "additionalProperties": false
}, },
"code": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"placeholder": {
"type": "string"
}
},
"required": ["label", "placeholder"],
"additionalProperties": false
},
"description": { "description": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -10320,7 +10333,7 @@
"additionalProperties": false "additionalProperties": false
} }
}, },
"required": ["label", "description"], "required": ["label", "code", "description"],
"additionalProperties": false "additionalProperties": false
} }
}, },
@@ -2771,6 +2771,10 @@
"label": "Label", "label": "Label",
"placeholder": "Gesture of goodwill" "placeholder": "Gesture of goodwill"
}, },
"code": {
"label": "Code",
"placeholder": "gesture_of_goodwill"
},
"description": { "description": {
"label": "Description", "label": "Description",
"placeholder": "Customer had a bad shopping experience" "placeholder": "Customer had a bad shopping experience"
@@ -1,14 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { import { Button, CurrencyInput, Select, Textarea, toast } from "@medusajs/ui"
Button, import { useMemo, useState } from "react"
CurrencyInput,
Label,
Select,
Textarea,
toast,
} from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
import { formatValue } from "react-currency-input-field" import { formatValue } from "react-currency-input-field"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -20,7 +13,6 @@ import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useRefundPayment, useRefundReasons } from "../../../../../hooks/api" import { useRefundPayment, useRefundReasons } from "../../../../../hooks/api"
import { currencies } from "../../../../../lib/data/currencies" import { currencies } from "../../../../../lib/data/currencies"
import { formatCurrency } from "../../../../../lib/format-currency" import { formatCurrency } from "../../../../../lib/format-currency"
import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders" import { getPaymentsFromOrder } from "../../../../../lib/orders"
import { useDocumentDirection } from "../../../../../hooks/use-document-direction" import { useDocumentDirection } from "../../../../../hooks/use-document-direction"
@@ -63,29 +55,10 @@ export const CreateRefundForm = ({ order }: CreateRefundFormProps) => {
value: paymentAmount.toFixed(currency.decimal_digits), value: paymentAmount.toFixed(currency.decimal_digits),
float: paymentAmount, float: paymentAmount,
}, },
note: "",
refund_reason_id: "",
}, },
resolver: zodResolver(CreateRefundSchema), resolver: zodResolver(CreateRefundSchema),
}) })
useEffect(() => {
const pendingDifference = order.summary.pending_difference as number
const paymentAmount = (payment?.amount || 0) as number
const pendingAmount =
pendingDifference < 0
? Math.min(Math.abs(pendingDifference), paymentAmount)
: paymentAmount
const normalizedAmount =
pendingAmount < 0 ? pendingAmount * -1 : pendingAmount
form.setValue("amount", {
value: normalizedAmount.toFixed(currency.decimal_digits),
float: normalizedAmount,
})
}, [payment?.id || ""])
const { mutateAsync, isPending } = useRefundPayment(order.id, payment?.id!) const { mutateAsync, isPending } = useRefundPayment(order.id, payment?.id!)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
@@ -123,53 +96,16 @@ export const CreateRefundForm = ({ order }: CreateRefundFormProps) => {
> >
<RouteDrawer.Body className="flex-1 overflow-auto"> <RouteDrawer.Body className="flex-1 overflow-auto">
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
<Select <div className="flex items-center">
dir={direction} <span>
value={paymentId} {getLocaleAmount(
onValueChange={(value) => { payment.amount as number,
setPaymentId(value) payment.currency_code
}} )}
> </span>
<Label className="txt-compact-small mb-[-6px] font-sans font-medium"> <span> - </span>
{t("orders.payment.selectPaymentToRefund")} <span>(#{payment.id.substring(23)})</span>
</Label> </div>
<Select.Trigger>
<Select.Value
placeholder={t("orders.payment.selectPaymentToRefund")}
/>
</Select.Trigger>
<Select.Content>
{payments.map((payment) => {
const totalRefunded = payment.refunds.reduce(
(acc, next) => next.amount + acc,
0
)
return (
<Select.Item
value={payment!.id}
key={payment.id}
disabled={
!!payment.canceled_at || totalRefunded >= payment.amount
}
className="flex items-center justify-center"
>
<span>
{getLocaleAmount(
payment.amount as number,
payment.currency_code
)}
{" - "}
</span>
<span>{formatProvider(payment.provider_id)}</span>
<span> - (#{payment.id.substring(23)})</span>
</Select.Item>
)
})}
</Select.Content>
</Select>
<Form.Field <Form.Field
control={form.control} control={form.control}
@@ -853,10 +853,6 @@ const DiscountAndTotalBreakdown = ({
.split("-") .split("-")
.join(" ") .join(" ")
const prettyReferenceId = creditLine.reference_id ? (
<DisplayId id={creditLine.reference_id} />
) : null
return ( return (
<div <div
key={creditLine.id} key={creditLine.id}
@@ -899,7 +895,7 @@ const DiscountAndTotalBreakdown = ({
leading="compact" leading="compact"
className="txt-small text-ui-fg-subtle capitalize" className="txt-small text-ui-fg-subtle capitalize"
> >
({prettyReference} {prettyReferenceId}) ({prettyReference})
</Text> </Text>
</div> </div>
<div className="relative flex-1"> <div className="relative flex-1">
@@ -13,6 +13,7 @@ import { useCreateRefundReason } from "../../../../../hooks/api"
const RefundReasonCreateSchema = z.object({ const RefundReasonCreateSchema = z.object({
label: z.string().min(1), label: z.string().min(1),
code: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
}) })
@@ -23,11 +24,20 @@ export const RefundReasonCreateForm = () => {
const form = useForm<z.infer<typeof RefundReasonCreateSchema>>({ const form = useForm<z.infer<typeof RefundReasonCreateSchema>>({
defaultValues: { defaultValues: {
label: "", label: "",
code: "",
description: "", description: "",
}, },
resolver: zodResolver(RefundReasonCreateSchema), resolver: zodResolver(RefundReasonCreateSchema),
}) })
const generateCodeFromLabel = (label: string) => {
return label
.toLowerCase()
.replace(/[^a-z0-9]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
}
const { mutateAsync, isPending } = useCreateRefundReason() const { mutateAsync, isPending } = useCreateRefundReason()
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
@@ -81,6 +91,42 @@ export const RefundReasonCreateForm = () => {
placeholder={t( placeholder={t(
"refundReasons.fields.label.placeholder" "refundReasons.fields.label.placeholder"
)} )}
onChange={(e) => {
if (
!form.getFieldState("code").isTouched ||
!form.getValues("code")
) {
form.setValue(
"code",
generateCodeFromLabel(e.target.value)
)
}
field.onChange(e)
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("refundReasons.fields.code.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t(
"refundReasons.fields.code.placeholder"
)}
/> />
</Form.Control> </Form.Control>
<Form.ErrorMessage /> <Form.ErrorMessage />
@@ -8,7 +8,7 @@ import { z } from "zod"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals" import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useUpdateRefundReason } from "../../../../../hooks/api/refund-reasons" import { useUpdateRefundReason } from "../../../../../hooks/api"
type RefundReasonEditFormProps = { type RefundReasonEditFormProps = {
refundReason: HttpTypes.AdminRefundReason refundReason: HttpTypes.AdminRefundReason
@@ -16,6 +16,7 @@ type RefundReasonEditFormProps = {
const RefundReasonEditSchema = z.object({ const RefundReasonEditSchema = z.object({
label: z.string().min(1), label: z.string().min(1),
code: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
}) })
@@ -28,6 +29,7 @@ export const RefundReasonEditForm = ({
const form = useForm<z.infer<typeof RefundReasonEditSchema>>({ const form = useForm<z.infer<typeof RefundReasonEditSchema>>({
defaultValues: { defaultValues: {
label: refundReason.label, label: refundReason.label,
code: refundReason.code,
description: refundReason.description ?? undefined, description: refundReason.description ?? undefined,
}, },
resolver: zodResolver(RefundReasonEditSchema), resolver: zodResolver(RefundReasonEditSchema),
@@ -78,6 +80,26 @@ export const RefundReasonEditForm = ({
) )
}} }}
/> />
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("refundReasons.fields.code.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t("refundReasons.fields.code.placeholder")}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field <Form.Field
control={form.control} control={form.control}
name="description" name="description"
@@ -1,15 +1,6 @@
import type { BigNumberInput, OrderDTO } from "@medusajs/framework/types" import type { BigNumberInput, OrderDTO } from "@medusajs/framework/types"
import { import { ChangeActionType, OrderChangeStatus, OrderChangeType, } from "@medusajs/framework/utils"
ChangeActionType, import { createStep, createWorkflow, transform, WorkflowData, } from "@medusajs/framework/workflows-sdk"
OrderChangeStatus,
OrderChangeType,
} from "@medusajs/framework/utils"
import {
WorkflowData,
createStep,
createWorkflow,
transform,
} 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"
@@ -36,8 +27,10 @@ export const createOrderRefundCreditLinesWorkflow = createWorkflow(
function ( function (
input: WorkflowData<{ input: WorkflowData<{
order_id: string order_id: string
amount: BigNumberInput,
reference?: string,
referenceId?: string
created_by?: string created_by?: string
amount: BigNumberInput
}> }>
) { ) {
const orderQuery = useQueryGraphStep({ const orderQuery = useQueryGraphStep({
@@ -69,8 +62,8 @@ export const createOrderRefundCreditLinesWorkflow = createWorkflow(
order_id: order.id, order_id: order.id,
version: orderChange.version, version: orderChange.version,
action: ChangeActionType.CREDIT_LINE_ADD, action: ChangeActionType.CREDIT_LINE_ADD,
reference: "payment_collection", reference: input.reference ?? "payment_collection",
reference_id: order.payment_collections[0]?.id, reference_id: input.referenceId ?? order.payment_collections[0]?.id,
amount: input.amount, amount: input.amount,
}) })
) )
@@ -1,12 +1,5 @@
import { import { CreateRefundReasonDTO, RefundReasonDTO, } from "@medusajs/framework/types"
CreateRefundReasonDTO, import { createWorkflow, WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk"
RefundReasonDTO,
} from "@medusajs/framework/types"
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
} from "@medusajs/framework/workflows-sdk"
import { createRefundReasonStep } from "../steps/create-refund-reasons" import { createRefundReasonStep } from "../steps/create-refund-reasons"
/** /**
@@ -33,7 +26,8 @@ export const createRefundReasonsWorkflowId = "create-refund-reasons-workflow"
* input: { * input: {
* data: [ * data: [
* { * {
* label: "damaged", * label: "Damaged",
* code: "damaged"
* } * }
* ] * ]
* } * }
@@ -1,9 +1,6 @@
import { import { BigNumberInput, IPaymentModuleService } from "@medusajs/framework/types"
BigNumberInput,
IPaymentModuleService,
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils" import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
/** /**
* The data to refund a payment. * The data to refund a payment.
@@ -1,89 +1,10 @@
import type { import { BigNumberInput } from "@medusajs/framework/types"
BigNumberInput, import { MathBN, PaymentEvents } from "@medusajs/framework/utils"
OrderDTO, import { createWorkflow, transform, when, WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk"
PaymentDTO,
} from "@medusajs/framework/types"
import { MathBN, MedusaError, PaymentEvents } from "@medusajs/framework/utils"
import {
WorkflowData,
WorkflowResponse,
createStep,
createWorkflow,
transform,
when,
} 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 { refundPaymentStep } from "../steps/refund-payment"
import { createOrderRefundCreditLinesWorkflow } from "../../order/workflows/payments/create-order-refund-credit-lines"
/**
* The data to validate whether the refund is valid for the order.
*/
export type ValidateRefundStepInput = {
/**
* The order's details.
*/
order: OrderDTO
/**
* The order's payment details.
*/
payment: PaymentDTO
/**
* The amound to refund.
*/
amount?: BigNumberInput
}
/**
* This step validates that the refund is valid for the order.
* If the order does not have an outstanding balance to refund, the step throws an error.
*
* :::note
*
* You can retrieve an order or payment's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query),
* or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep).
*
* :::
*
* @example
* const data = validateRefundStep({
* order: {
* id: "order_123",
* // other order details...
* },
* payment: {
* id: "payment_123",
* // other payment details...
* },
* amount: 10
* })
*/
export const validateRefundStep = createStep(
"validate-refund-step",
async function ({ order, payment, amount }: ValidateRefundStepInput) {
const pendingDifference =
order.summary?.raw_pending_difference! ??
order.summary?.pending_difference! ??
0
if (MathBN.gte(pendingDifference, 0)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Order does not have an outstanding balance to refund`
)
}
const amountPending = MathBN.mult(pendingDifference, -1)
const amountToRefund = amount ?? payment.raw_amount ?? payment.amount
if (MathBN.gt(amountToRefund, amountPending)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot refund more than pending difference - ${amountPending}`
)
}
}
)
/** /**
* The data to refund a payment. * The data to refund a payment.
@@ -101,6 +22,14 @@ export type RefundPaymentWorkflowInput = {
* The amount to refund. If not provided, the full payment amount will be refunded. * The amount to refund. If not provided, the full payment amount will be refunded.
*/ */
amount?: BigNumberInput amount?: BigNumberInput
/**
* The note to attach to the refund.
*/
note?: string
/**
* The ID of the refund reason to attach to the refund.
*/
refund_reason_id?: string
} }
export const refundPaymentWorkflowId = "refund-payment-workflow" export const refundPaymentWorkflowId = "refund-payment-workflow"
@@ -156,7 +85,46 @@ export const refundPaymentWorkflow = createWorkflow(
list: false, list: false,
}).config({ name: "order" }) }).config({ name: "order" })
validateRefundStep({ order, payment, amount: input.amount }) const refundReason = when(
"fetch-refund-reason",
{ input }, ({ input }) =>
!!input.refund_reason_id
).then(() => {
return useRemoteQueryStep({
entry_point: "refund_reason",
fields: ["id", "label", "code"],
variables: { id: input.refund_reason_id },
list: false,
throw_if_key_not_found: true,
}).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)
when({ orderPaymentCollection }, ({ orderPaymentCollection }) => { when({ orderPaymentCollection }, ({ orderPaymentCollection }) => {
@@ -6,6 +6,13 @@ type AdminBaseRefundReasonPayload = {
* "Refund" * "Refund"
*/ */
label: string label: string
/**
* The refund reason's code.
*
* @example
* "refund"
*/
code: string
/** /**
* The refund reason's description. * The refund reason's description.
*/ */
@@ -13,6 +13,13 @@ export interface BaseRefundReason {
* "Refund" * "Refund"
*/ */
label: string label: string
/**
* The refund reason's code.
*
* @example
* "refund"
*/
code: string
/** /**
* The refund reason's description. * The refund reason's description.
*/ */
+5 -5
View File
@@ -1,10 +1,6 @@
import { BigNumberInput } from "../totals" import { BigNumberInput } from "../totals"
import { PaymentCollectionStatus, PaymentSessionStatus } from "./common" import { PaymentCollectionStatus, PaymentSessionStatus } from "./common"
import { import { PaymentAccountHolderDTO, PaymentCustomerDTO, PaymentProviderContext, } from "./provider"
PaymentAccountHolderDTO,
PaymentCustomerDTO,
PaymentProviderContext,
} from "./provider"
/** /**
* The payment collection to be created. * The payment collection to be created.
@@ -356,6 +352,10 @@ export interface CreateRefundReasonDTO {
* The label of the refund reason * The label of the refund reason
*/ */
label: string label: string
/**
* The code of the refund reason
*/
code: string
/** /**
* The description of the refund reason * The description of the refund reason
*/ */
+7 -4
View File
@@ -22,20 +22,20 @@ import {
RefundReasonDTO, RefundReasonDTO,
} from "./common" } from "./common"
import { import {
CreateAccountHolderDTO,
CreateCaptureDTO, CreateCaptureDTO,
CreatePaymentCollectionDTO, CreatePaymentCollectionDTO,
CreatePaymentMethodDTO,
CreatePaymentSessionDTO, CreatePaymentSessionDTO,
CreateRefundDTO, CreateRefundDTO,
CreateRefundReasonDTO, CreateRefundReasonDTO,
PaymentCollectionUpdatableFields, PaymentCollectionUpdatableFields,
ProviderWebhookPayload, ProviderWebhookPayload,
UpdateAccountHolderDTO,
UpdatePaymentDTO, UpdatePaymentDTO,
UpdatePaymentSessionDTO, UpdatePaymentSessionDTO,
UpdateRefundReasonDTO, UpdateRefundReasonDTO,
CreateAccountHolderDTO,
UpsertPaymentCollectionDTO, UpsertPaymentCollectionDTO,
CreatePaymentMethodDTO,
UpdateAccountHolderDTO,
} from "./mutations" } from "./mutations"
import { WebhookActionResult } from "./provider" import { WebhookActionResult } from "./provider"
@@ -1242,9 +1242,11 @@ export interface IPaymentModuleService extends IModuleService {
* await paymentModuleService.createRefundReasons([ * await paymentModuleService.createRefundReasons([
* { * {
* label: "Too big", * label: "Too big",
* code: "too_big
* }, * },
* { * {
* label: "Too big", * label: "Too small",
* code: "too_small
* }, * },
* ]) * ])
*/ */
@@ -1264,6 +1266,7 @@ export interface IPaymentModuleService extends IModuleService {
* const refundReason = * const refundReason =
* await paymentModuleService.createRefundReasons({ * await paymentModuleService.createRefundReasons({
* label: "Too big", * label: "Too big",
* code: "too_big"
* }) * })
*/ */
createRefundReasons( createRefundReasons(
@@ -12,6 +12,7 @@ export const defaultAdminPaymentFields = [
"refunds.note", "refunds.note",
"refunds.payment_id", "refunds.payment_id",
"refunds.refund_reason.label", "refunds.refund_reason.label",
"refunds.refund_reason.code",
] ]
export const listTransformQueryConfig = { export const listTransformQueryConfig = {
@@ -1,6 +1,7 @@
export const defaultAdminRefundReasonFields = [ export const defaultAdminRefundReasonFields = [
"id", "id",
"label", "label",
"code",
"description", "description",
"created_at", "created_at",
"updated_at", "updated_at",
@@ -7,6 +7,7 @@ export type AdminCreatePaymentRefundReasonType = z.infer<
export const AdminCreatePaymentRefundReason = z export const AdminCreatePaymentRefundReason = z
.object({ .object({
label: z.string(), label: z.string(),
code: z.string(),
description: z.string().nullish(), description: z.string().nullish(),
}) })
.strict() .strict()
@@ -17,6 +18,7 @@ export type AdminUpdatePaymentRefundReasonType = z.infer<
export const AdminUpdatePaymentRefundReason = z export const AdminUpdatePaymentRefundReason = z
.object({ .object({
label: z.string().optional(), label: z.string().optional(),
code: z.string().optional(),
description: z.string().nullish(), description: z.string().nullish(),
}) })
.strict() .strict()
+4 -4
View File
@@ -30,10 +30,10 @@
"build": "rimraf dist && tsc --build && npm run resolve:aliases", "build": "rimraf dist && tsc --build && npm run resolve:aliases",
"test": "jest --bail --passWithNoTests --forceExit -- src", "test": "jest --bail --passWithNoTests --forceExit -- src",
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts", "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts",
"migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create --initial", "migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro migration:create --initial",
"migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm-orm migration:create", "migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create",
"migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm-orm migration:up", "migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:up",
"orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm-orm cache:clear" "orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm cache:clear"
}, },
"devDependencies": { "devDependencies": {
"@medusajs/framework": "2.10.3", "@medusajs/framework": "2.10.3",
@@ -0,0 +1,24 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250929124701 extends Migration {
override async up(): Promise<void> {
// Step 1: Add the column as nullable
this.addSql(
`alter table "refund_reason" add column "code" text;`
)
// Step 2: Populate the code column from label (convert to snake_case)
this.addSql(`
update "refund_reason"
set "code" = lower(replace("label", ' ', '_'));
`)
// Step 3: Set the column to not nullable
this.addSql(`alter table "refund_reason" alter column "code" set not null;`)
}
override async down(): Promise<void> {
// Remove the code column
this.addSql(`alter table "refund_reason" drop column "code";`)
}
}
@@ -4,6 +4,7 @@ import Refund from "./refund"
const RefundReason = model.define("RefundReason", { const RefundReason = model.define("RefundReason", {
id: model.id({ prefix: "refr" }).primaryKey(), id: model.id({ prefix: "refr" }).primaryKey(),
label: model.text().searchable(), label: model.text().searchable(),
code: model.text().searchable(),
description: model.text().searchable().nullable(), description: model.text().searchable().nullable(),
metadata: model.json().nullable(), metadata: model.json().nullable(),
refunds: model.hasMany(() => Refund, { refunds: model.hasMany(() => Refund, {