feat(dashboard): admin 3.0 order refund form (#6850)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
fix(medusa): associate refund with order when calling `refundFromPayment`
|
||||
@@ -56,6 +56,7 @@
|
||||
"remove": "Remove",
|
||||
"revoke": "Revoke",
|
||||
"cancel": "Cancel",
|
||||
"complete": "Complete",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"confirm": "Confirm",
|
||||
@@ -330,6 +331,17 @@
|
||||
"shippingFromLabel": "Shipping from",
|
||||
"itemsLabel": "Items"
|
||||
},
|
||||
"refund": {
|
||||
"title": "Create Refund",
|
||||
"sendNotificationHint": "Notify customers about the created refund.",
|
||||
"systemPayment": "System payment",
|
||||
"systemPaymentDesc": "One or more of your payments is a system payment. Be aware, that captures and refunds are not handled by Medusa for such payments.",
|
||||
"error": {
|
||||
"amountToLarge": "Cannot refund more than the original order amount.",
|
||||
"amountNegative": "Refund amount must be a positive number.",
|
||||
"reasonRequired": "Please select a refund reason."
|
||||
}
|
||||
},
|
||||
"customer": {
|
||||
"contactLabel": "Contact",
|
||||
"editEmail": "Edit email",
|
||||
@@ -819,6 +831,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"amount": "Amount",
|
||||
"refundAmount": "Refund amount",
|
||||
"name": "Name",
|
||||
"lastName": "Last Name",
|
||||
"firstName": "First Name",
|
||||
|
||||
@@ -129,6 +129,10 @@ export const v1Routes: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-create-return"),
|
||||
},
|
||||
{
|
||||
path: "refund",
|
||||
lazy: () => import("../../routes/orders/order-refund"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+6
-2
@@ -42,6 +42,8 @@ export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
|
||||
const Header = ({ order }: { order: Order }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const hasCapturedPayment = order.payments.some((p) => !!p.captured_at)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("orders.payment.title")}</Heading>
|
||||
@@ -52,7 +54,8 @@ const Header = ({ order }: { order: Order }) => {
|
||||
{
|
||||
label: t("orders.payment.refund"),
|
||||
icon: <ArrowDownRightMini />,
|
||||
to: "#", // TODO: Go to general refund modal
|
||||
to: `/orders/${order.id}/refund`,
|
||||
disabled: !hasCapturedPayment,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -164,7 +167,8 @@ const Payment = ({
|
||||
{
|
||||
label: t("orders.payment.refund"),
|
||||
icon: <XCircle />,
|
||||
to: "#", // TODO: Go to specific payment refund modal
|
||||
to: `/orders/${payment.order_id}/refund?paymentId=${payment.id}`,
|
||||
disabled: !payment.captured_at,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -4,12 +4,15 @@ import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { EditOrderEmailForm } from "./components/edit-order-email-form"
|
||||
import { orderExpand } from "../order-detail/constants"
|
||||
|
||||
export const OrderEmail = () => {
|
||||
const { id } = useParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { order, isLoading, isError, error } = useAdminOrder(id!)
|
||||
const { order, isLoading, isError, error } = useAdminOrder(id!, {
|
||||
expand: orderExpand,
|
||||
})
|
||||
|
||||
const ready = !isLoading && order
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./order-refund-form"
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { Order, Payment } from "@medusajs/medusa"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
CurrencyInput,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useForm } from "react-hook-form"
|
||||
import {
|
||||
adminOrderKeys,
|
||||
useAdminPaymentsRefundPayment,
|
||||
useAdminRefundPayment,
|
||||
} from "medusa-react"
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
import { CreateRefundSchema } from "../../schema"
|
||||
import { castNumber } from "../../../../../lib/cast-number"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { getCurrencySymbol } from "../../../../../lib/currencies"
|
||||
import { queryClient } from "../../../../../lib/medusa"
|
||||
import {
|
||||
getDbAmount,
|
||||
getPresentationalAmount,
|
||||
} from "../../../../../lib/money-amount-helpers"
|
||||
|
||||
const reasonOptions = [
|
||||
{ label: "Discount", value: "discount" },
|
||||
{ label: "Other", value: "other" },
|
||||
]
|
||||
|
||||
type OrderRefundFormProps = {
|
||||
order: Order
|
||||
payment?: Payment
|
||||
}
|
||||
|
||||
export function OrderRefundForm({ order, payment }: OrderRefundFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const isSpecificPaymentRefund = !!payment
|
||||
|
||||
const refundable = useMemo(() => {
|
||||
return order.paid_total - order.refunded_total
|
||||
}, [order])
|
||||
|
||||
const form = useForm<z.infer<typeof CreateRefundSchema>>({
|
||||
defaultValues: {
|
||||
amount: isSpecificPaymentRefund
|
||||
? getPresentationalAmount(payment!.amount, order.currency_code)
|
||||
: "",
|
||||
note: "",
|
||||
reason: "",
|
||||
notification_enabled: !order.no_notification,
|
||||
},
|
||||
resolver: zodResolver(CreateRefundSchema),
|
||||
})
|
||||
|
||||
let { mutateAsync: createOrderRefund, isLoading: isOrderRefundLoading } =
|
||||
useAdminRefundPayment(order.id)
|
||||
let { mutateAsync: createPaymentRefund, isLoading: isPaymentRefundLoading } =
|
||||
useAdminPaymentsRefundPayment(payment?.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
if (values.amount === "" || values.amount === undefined) {
|
||||
form.setError("amount", {
|
||||
type: "manual",
|
||||
message: t("Please enter an amount for refund"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const amount = castNumber(values.amount)
|
||||
const dbAmount = getDbAmount(amount, order.currency_code)
|
||||
|
||||
if (dbAmount > refundable) {
|
||||
form.setError("amount", {
|
||||
type: "manual",
|
||||
message: t("orders.refund.error.amountToLarge"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (amount < 0) {
|
||||
form.setError("amount", {
|
||||
type: "manual",
|
||||
message: t("orders.refund.error.amountNegative"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
amount: dbAmount,
|
||||
note: values.note,
|
||||
reason: values.reason,
|
||||
no_notification: !values.notification_enabled,
|
||||
}
|
||||
|
||||
const mutate = isSpecificPaymentRefund
|
||||
? createPaymentRefund
|
||||
: createOrderRefund
|
||||
|
||||
if (isSpecificPaymentRefund) {
|
||||
delete payload.no_notification
|
||||
}
|
||||
|
||||
await mutate(payload)
|
||||
|
||||
if (isSpecificPaymentRefund) {
|
||||
await queryClient.invalidateQueries(adminOrderKeys.detail(order.id))
|
||||
}
|
||||
|
||||
handleSuccess()
|
||||
})
|
||||
|
||||
const isSystemPayment = order.payments.some((p) => p.provider_id === "system")
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex size-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="amount"
|
||||
led={isSpecificPaymentRefund}
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.refundAmount")}</Form.Label>
|
||||
<Form.Control>
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
max={refundable}
|
||||
onValueChange={onChange}
|
||||
code={order.currency_code}
|
||||
symbol={getCurrencySymbol(order.currency_code)}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field: { onChange, ref, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.reason")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Select onValueChange={onChange} {...field}>
|
||||
<Select.Trigger className="txt-small" ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{reasonOptions.map((i) => (
|
||||
<Select.Item key={i.value} value={i.value}>
|
||||
{i.label}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="note"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.note")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Textarea {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="notification_enabled"
|
||||
render={({ field: { onChange, value, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex items-center justify-between">
|
||||
<Form.Label>
|
||||
{t("orders.returns.sendNotification")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Form.Control>
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Form.Hint className="!mt-1">
|
||||
{t("orders.refund.sendNotificationHint")}
|
||||
</Form.Hint>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{isSystemPayment && (
|
||||
<Alert variant="warning" dismissible className="mt-8 p-5">
|
||||
<div className="text-ui-fg-subtle txt-small pb-2 font-medium leading-[20px]">
|
||||
{t("orders.refund.systemPayment")}
|
||||
</div>
|
||||
<Text className="text-ui-fg-subtle txt-small leading-normal">
|
||||
{t("orders.refund.systemPaymentDesc")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isOrderRefundLoading || isPaymentRefundLoading}
|
||||
size="small"
|
||||
>
|
||||
{t("actions.complete")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderRefundForm
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderRefund as Component } from "./order-refund"
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useAdminOrder } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams, useSearchParams } from "react-router-dom"
|
||||
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { OrderRefundForm } from "./components/order-refund-form"
|
||||
import { orderExpand } from "../order-detail/constants"
|
||||
|
||||
export const OrderRefund = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { id } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const paymentId = searchParams.get("paymentId")
|
||||
|
||||
const { order, isLoading, isError, error } = useAdminOrder(id!, {
|
||||
expand: orderExpand,
|
||||
})
|
||||
|
||||
const ready = !isLoading && order
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const payment = paymentId
|
||||
? order.payments.find((p) => p.id === paymentId)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("orders.refund.title")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
{ready && <OrderRefundForm order={order} payment={payment} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod"
|
||||
import i18n from "i18next"
|
||||
|
||||
export enum RefundReason {
|
||||
Discount = "discount",
|
||||
Other = "other",
|
||||
}
|
||||
|
||||
export const CreateRefundSchema = z.object({
|
||||
amount: z.union([z.number(), z.string()]),
|
||||
reason: z.nativeEnum(RefundReason, {
|
||||
errorMap: () => ({
|
||||
message: i18n.t("orders.refund.error.reasonRequired"),
|
||||
}),
|
||||
}),
|
||||
note: z.string().optional(),
|
||||
notification_enabled: z.boolean().optional(),
|
||||
})
|
||||
@@ -809,6 +809,7 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
|
||||
const toCreate = {
|
||||
payment_id: payment.id,
|
||||
order_id: payment.order_id,
|
||||
amount,
|
||||
reason,
|
||||
note,
|
||||
|
||||
Reference in New Issue
Block a user