feat(dashboard): admin 3.0 order refund form (#6850)
This commit is contained in:
1 parent
1a48fe0282
commit
e58e81fd25
11 files changed
+355
-3
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(medusa): associate refund with order when calling `refundFromPayment`
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"revoke": "Revoke",
|
"revoke": "Revoke",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"complete": "Complete",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@@ -330,6 +331,17 @@
|
|||||||
"shippingFromLabel": "Shipping from",
|
"shippingFromLabel": "Shipping from",
|
||||||
"itemsLabel": "Items"
|
"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": {
|
"customer": {
|
||||||
"contactLabel": "Contact",
|
"contactLabel": "Contact",
|
||||||
"editEmail": "Edit email",
|
"editEmail": "Edit email",
|
||||||
@@ -819,6 +831,7 @@
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
|
"refundAmount": "Refund amount",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"lastName": "Last Name",
|
"lastName": "Last Name",
|
||||||
"firstName": "First Name",
|
"firstName": "First Name",
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ export const v1Routes: RouteObject[] = [
|
|||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../routes/orders/order-create-return"),
|
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 Header = ({ order }: { order: Order }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const hasCapturedPayment = order.payments.some((p) => !!p.captured_at)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<Heading level="h2">{t("orders.payment.title")}</Heading>
|
<Heading level="h2">{t("orders.payment.title")}</Heading>
|
||||||
@@ -52,7 +54,8 @@ const Header = ({ order }: { order: Order }) => {
|
|||||||
{
|
{
|
||||||
label: t("orders.payment.refund"),
|
label: t("orders.payment.refund"),
|
||||||
icon: <ArrowDownRightMini />,
|
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"),
|
label: t("orders.payment.refund"),
|
||||||
icon: <XCircle />,
|
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 { useParams } from "react-router-dom"
|
||||||
import { RouteDrawer } from "../../../components/route-modal"
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
import { EditOrderEmailForm } from "./components/edit-order-email-form"
|
import { EditOrderEmailForm } from "./components/edit-order-email-form"
|
||||||
|
import { orderExpand } from "../order-detail/constants"
|
||||||
|
|
||||||
export const OrderEmail = () => {
|
export const OrderEmail = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { order, isLoading, isError, error } = useAdminOrder(id!)
|
const { order, isLoading, isError, error } = useAdminOrder(id!, {
|
||||||
|
expand: orderExpand,
|
||||||
|
})
|
||||||
|
|
||||||
const ready = !isLoading && order
|
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 = {
|
const toCreate = {
|
||||||
payment_id: payment.id,
|
payment_id: payment.id,
|
||||||
|
order_id: payment.order_id,
|
||||||
amount,
|
amount,
|
||||||
reason,
|
reason,
|
||||||
note,
|
note,
|
||||||
|
|||||||
Reference in new issue
Block a user