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:
Riqwan Thamir
2024-08-01 19:13:41 +02:00
committed by GitHub
parent 6efdba1967
commit 7ae1d80380
14 changed files with 308 additions and 15 deletions

View File

@@ -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,
})
}

View File

@@ -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": {

View File

@@ -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>>(

View File

@@ -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"),
},
],
},
],

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-refund-form"

View File

@@ -0,0 +1 @@
export { OrderCreateRefund as Component } from "./order-create-refund"

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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,
}
)
}
}

View File

@@ -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 {}

View File

@@ -420,3 +420,7 @@ export interface BasePaymentProviderFilters
id?: string | string[]
region_id?: string | string[]
}
export interface BasePaymentFilters extends BaseFilterable<BasePaymentFilters> {
id?: string | string[]
}

View File

@@ -9,6 +9,7 @@ export const defaultAdminPaymentFields = [
"captures.amount",
"refunds.id",
"refunds.amount",
"refunds.payment_id",
]
export const listTransformQueryConfig = {

View File

@@ -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",