feat(dashboard): admin 3.0 order refund form (#6850)

This commit is contained in:
Frane Polić
2024-04-01 10:55:42 +02:00
committed by GitHub
parent 1a48fe0282
commit e58e81fd25
11 changed files with 355 additions and 3 deletions
+5
View File
@@ -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"),
},
],
},
],
@@ -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
@@ -0,0 +1 @@
export * from "./order-refund-form"
@@ -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,