feat(dashboard,types,js-sdk,payment): ability to refund payment in order page (#8385)
* feat(dashboard,types,js-sdk,payment): ability to refund payment in order page * chore: use confirmation variant for capture payment * chore: change refund design accords to figma * chore: move to js-sdk + currency input
This commit is contained in:
@@ -8,9 +8,13 @@ import {
|
||||
} from "@tanstack/react-query"
|
||||
import { client, sdk } from "../../lib/client"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { PaymentProvidersListRes } from "../../types/api-responses"
|
||||
import { ordersQueryKeys } from "./orders"
|
||||
|
||||
const PAYMENT_QUERY_KEY = "payment" as const
|
||||
export const paymentQueryKeys = queryKeysFactory(PAYMENT_QUERY_KEY)
|
||||
|
||||
export const usePaymentProviders = (
|
||||
query?: Record<string, any>,
|
||||
options?: Omit<
|
||||
@@ -32,6 +36,28 @@ export const usePaymentProviders = (
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const usePayment = (
|
||||
id: string,
|
||||
query?: HttpTypes.AdminPaymentFilters,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
HttpTypes.AdminPaymentResponse,
|
||||
Error,
|
||||
HttpTypes.AdminPaymentResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryFn: () => sdk.admin.payment.retrieve(id, query),
|
||||
queryKey: paymentQueryKeys.detail(id),
|
||||
...options,
|
||||
})
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useCapturePayment = (
|
||||
paymentId: string,
|
||||
options?: UseMutationOptions<
|
||||
@@ -56,3 +82,28 @@ export const useCapturePayment = (
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useRefundPayment = (
|
||||
paymentId: string,
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminPaymentResponse,
|
||||
Error,
|
||||
HttpTypes.AdminRefundPayment
|
||||
>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) => sdk.admin.payment.refund(paymentId, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersQueryKeys.details(),
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersQueryKeys.lists(),
|
||||
})
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -826,7 +826,10 @@
|
||||
"requiresAction": "Requires action"
|
||||
},
|
||||
"capturePayment": "Payment of {{amount}} will be captured.",
|
||||
"capturePaymentSuccess": "Payment of {{amount}} successfully captured"
|
||||
"capturePaymentSuccess": "Payment of {{amount}} successfully captured",
|
||||
"createRefund": "Create Refund",
|
||||
"refundPaymentSuccess": "Refund of amount {{amount}} successful",
|
||||
"createRefundWrongQuantity": "Quantity should be a number between 1 and {{number}}"
|
||||
},
|
||||
|
||||
"edits": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getRequest } from "./common"
|
||||
import { PaymentProvidersListRes } from "../../types/api-responses"
|
||||
import { getRequest } from "./common"
|
||||
|
||||
async function listPaymentProviders(query?: Record<string, any>) {
|
||||
return getRequest<PaymentProvidersListRes, Record<string, any>>(
|
||||
|
||||
@@ -238,6 +238,11 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-create-return"),
|
||||
},
|
||||
{
|
||||
path: "payments/:paymentId/refund",
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-create-refund"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, CurrencyInput, toast } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
|
||||
import { useRefundPayment } from "../../../../../hooks/api"
|
||||
import { getCurrencySymbol } from "../../../../../lib/data/currencies"
|
||||
import { formatCurrency } from "../../../../../lib/format-currency"
|
||||
|
||||
type CreateRefundFormProps = {
|
||||
payment: HttpTypes.AdminPayment
|
||||
}
|
||||
|
||||
const CreateRefundSchema = zod.object({
|
||||
amount: zod.number(),
|
||||
})
|
||||
|
||||
export const CreateRefundForm = ({ payment }: CreateRefundFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const paymentAmount = payment.amount as unknown as number
|
||||
|
||||
const form = useForm<zod.infer<typeof CreateRefundSchema>>({
|
||||
defaultValues: {
|
||||
amount: paymentAmount,
|
||||
},
|
||||
resolver: zodResolver(CreateRefundSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useRefundPayment(payment.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
amount: data.amount,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("orders.payment.refundPaymentSuccess", {
|
||||
amount: formatCurrency(data.amount, payment.currency_code),
|
||||
})
|
||||
)
|
||||
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
|
||||
<RouteDrawer.Body>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="amount"
|
||||
rules={{
|
||||
required: true,
|
||||
min: 0,
|
||||
max: paymentAmount,
|
||||
}}
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.amount")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<CurrencyInput
|
||||
{...field}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
e.target.value === ""
|
||||
? null
|
||||
: Number(e.target.value)
|
||||
|
||||
onChange(val)
|
||||
|
||||
if (val && !isNaN(val)) {
|
||||
if (val < 0 || val > paymentAmount) {
|
||||
form.setError(`amount`, {
|
||||
type: "manual",
|
||||
message: t(
|
||||
"orders.payment.createRefundWrongQuantity",
|
||||
{ number: paymentAmount }
|
||||
),
|
||||
})
|
||||
} else {
|
||||
form.clearErrors(`amount`)
|
||||
}
|
||||
}
|
||||
}}
|
||||
code={payment.currency_code}
|
||||
symbol={getCurrencySymbol(payment.currency_code)}
|
||||
value={field.value}
|
||||
/>
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
isLoading={isPending}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={!!Object.keys(form.formState.errors || {}).length}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./create-refund-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderCreateRefund as Component } from "./order-create-refund"
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/modals"
|
||||
import { usePayment } from "../../../hooks/api"
|
||||
import { CreateRefundForm } from "./components/create-refund-form"
|
||||
|
||||
export const OrderCreateRefund = () => {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
const { payment, isLoading, isError, error } = usePayment(params.paymentId!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("orders.payment.createRefund")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
|
||||
{!isLoading && payment && <CreateRefundForm payment={payment} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
|
||||
<Header />
|
||||
|
||||
<PaymentBreakdown
|
||||
order={order}
|
||||
payments={payments}
|
||||
refunds={refunds}
|
||||
currencyCode={order.currency_code}
|
||||
@@ -77,7 +78,6 @@ const Refund = ({
|
||||
currencyCode: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const hasPayment = refund.payment_id !== null
|
||||
|
||||
const BadgeComponent = (
|
||||
<Badge size="2xsmall" className="cursor-default select-none capitalize">
|
||||
@@ -93,17 +93,20 @@ const Refund = ({
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle text-ui-fg-subtle grid grid-cols-[1fr_1fr_1fr_1fr_20px] items-center gap-x-4 px-6 py-4">
|
||||
<div>
|
||||
{hasPayment && <ArrowDownRightMini className="text-ui-fg-muted" />}
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("orders.payment.refund")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Text size="small" leading="compact">
|
||||
{format(new Date(refund.created_at), "dd MMM, yyyy, HH:mm:ss")}
|
||||
</Text>
|
||||
<div className="flex flex-row">
|
||||
<div className="self-center pr-3">
|
||||
<ArrowDownRightMini className="text-ui-fg-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("orders.payment.refund")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
{format(new Date(refund.created_at), "dd MMM, yyyy, HH:mm:ss")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end"></div>
|
||||
<div className="flex items-center justify-end">{Render}</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Text size="small" leading="compact">
|
||||
@@ -115,10 +118,12 @@ const Refund = ({
|
||||
}
|
||||
|
||||
const Payment = ({
|
||||
order,
|
||||
payment,
|
||||
refunds,
|
||||
currencyCode,
|
||||
}: {
|
||||
order: HttpTypes.AdminOrder
|
||||
payment: MedusaPayment
|
||||
refunds: MedusaRefund[]
|
||||
currencyCode: string
|
||||
@@ -135,6 +140,7 @@ const Payment = ({
|
||||
}),
|
||||
confirmText: t("actions.confirm"),
|
||||
cancelText: t("actions.cancel"),
|
||||
variant: "confirmation",
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
@@ -204,7 +210,7 @@ const Payment = ({
|
||||
{
|
||||
label: t("orders.payment.refund"),
|
||||
icon: <XCircle />,
|
||||
to: `/orders/${payment.order_id}/refund?paymentId=${payment.id}`,
|
||||
to: `/orders/${order.id}/payments/${payment.id}/refund`,
|
||||
disabled: !payment.captured_at,
|
||||
},
|
||||
],
|
||||
@@ -236,10 +242,12 @@ const Payment = ({
|
||||
}
|
||||
|
||||
const PaymentBreakdown = ({
|
||||
order,
|
||||
payments,
|
||||
refunds,
|
||||
currencyCode,
|
||||
}: {
|
||||
order: HttpTypes.AdminOrder
|
||||
payments: MedusaPayment[]
|
||||
refunds: MedusaRefund[]
|
||||
currencyCode: string
|
||||
@@ -271,6 +279,7 @@ const PaymentBreakdown = ({
|
||||
return (
|
||||
<Payment
|
||||
key={event.id}
|
||||
order={order}
|
||||
payment={event}
|
||||
refunds={refunds.filter(
|
||||
(refund) => refund.payment_id === event.id
|
||||
|
||||
@@ -8,13 +8,37 @@ export class Payment {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async list(query?: HttpTypes.AdminPaymentFilters, headers?: ClientHeaders) {
|
||||
return await this.client.fetch<HttpTypes.AdminPaymentsResponse>(
|
||||
`/admin/payments`,
|
||||
{
|
||||
query,
|
||||
headers,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async retrieve(
|
||||
id: string,
|
||||
query?: HttpTypes.AdminPaymentFilters,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return await this.client.fetch<HttpTypes.AdminPaymentResponse>(
|
||||
`/admin/payments/${id}`,
|
||||
{
|
||||
query,
|
||||
headers,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async capture(
|
||||
id: string,
|
||||
body: HttpTypes.AdminCapturePayment,
|
||||
query?: SelectParams,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return await this.client.fetch<{ payment: HttpTypes.AdminPayment }>(
|
||||
return await this.client.fetch<HttpTypes.AdminPaymentResponse>(
|
||||
`/admin/payments/${id}/capture`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -24,4 +48,21 @@ export class Payment {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async refund(
|
||||
id: string,
|
||||
body: HttpTypes.AdminRefundPayment,
|
||||
query?: SelectParams,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return await this.client.fetch<HttpTypes.AdminPaymentResponse>(
|
||||
`/admin/payments/${id}/refund`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
query,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BasePayment,
|
||||
BasePaymentCollection,
|
||||
BasePaymentCollectionFilters,
|
||||
BasePaymentFilters,
|
||||
BasePaymentProvider,
|
||||
BasePaymentProviderFilters,
|
||||
BasePaymentSession,
|
||||
@@ -28,6 +29,16 @@ export interface AdminCapturePayment {
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export interface AdminRefundPayment {
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export interface AdminPaymentResponse {
|
||||
payment: AdminPayment
|
||||
}
|
||||
|
||||
export interface AdminPaymentsResponse {
|
||||
payments: AdminPayment[]
|
||||
}
|
||||
|
||||
export interface AdminPaymentFilters extends BasePaymentFilters {}
|
||||
|
||||
@@ -420,3 +420,7 @@ export interface BasePaymentProviderFilters
|
||||
id?: string | string[]
|
||||
region_id?: string | string[]
|
||||
}
|
||||
|
||||
export interface BasePaymentFilters extends BaseFilterable<BasePaymentFilters> {
|
||||
id?: string | string[]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const defaultAdminPaymentFields = [
|
||||
"captures.amount",
|
||||
"refunds.id",
|
||||
"refunds.amount",
|
||||
"refunds.payment_id",
|
||||
]
|
||||
|
||||
export const listTransformQueryConfig = {
|
||||
|
||||
@@ -33,6 +33,9 @@ export default class Refund {
|
||||
})
|
||||
payment!: Rel<Payment>
|
||||
|
||||
@Property({ columnType: "text", nullable: true })
|
||||
payment_id: string
|
||||
|
||||
@Property({
|
||||
onCreate: () => new Date(),
|
||||
columnType: "timestamptz",
|
||||
|
||||
Reference in New Issue
Block a user