feat(dashboard,js-sdk,types): add ability to capture payment from order page (#8368)

what:

- adds ability to capture payment from order page

bugs found:

- when capturing payment of one order, other orders statuses get affected from "captured" to "partially captured". Will investigate this separately. 

https://github.com/user-attachments/assets/0a1beac2-74fc-4803-8528-8de5913964d4
This commit is contained in:
Riqwan Thamir
2024-08-01 06:46:03 +00:00
committed by GitHub
parent 4a7b2fd625
commit 3169543d56
10 changed files with 160 additions and 30 deletions
@@ -1,6 +1,15 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query" import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { client, sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { PaymentProvidersListRes } from "../../types/api-responses" import { PaymentProvidersListRes } from "../../types/api-responses"
import { client } from "../../lib/client" import { ordersQueryKeys } from "./orders"
export const usePaymentProviders = ( export const usePaymentProviders = (
query?: Record<string, any>, query?: Record<string, any>,
@@ -22,3 +31,28 @@ export const usePaymentProviders = (
return { ...data, ...rest } return { ...data, ...rest }
} }
export const useCapturePayment = (
paymentId: string,
options?: UseMutationOptions<
HttpTypes.AdminPaymentResponse,
Error,
HttpTypes.AdminCapturePayment
>
) => {
return useMutation({
mutationFn: (payload) => sdk.admin.payment.capture(paymentId, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -809,7 +809,7 @@
"title": "Payments", "title": "Payments",
"isReadyToBeCaptured": "Payment {{id}} is ready to be captured.", "isReadyToBeCaptured": "Payment {{id}} is ready to be captured.",
"totalPaidByCustomer": "Total paid by customer", "totalPaidByCustomer": "Total paid by customer",
"capture": "Capture", "capture": "Capture payment",
"refund": "Refund", "refund": "Refund",
"statusLabel": "Payment status", "statusLabel": "Payment status",
"statusTitle": "Payment Status", "statusTitle": "Payment Status",
@@ -824,7 +824,9 @@
"refunded": "Refunded", "refunded": "Refunded",
"canceled": "Canceled", "canceled": "Canceled",
"requiresAction": "Requires action" "requiresAction": "Requires action"
} },
"capturePayment": "Payment of {{amount}} will be captured.",
"capturePaymentSuccess": "Payment of {{amount}} successfully captured"
}, },
"edits": { "edits": {
@@ -0,0 +1,7 @@
export const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
signDisplay: "always",
}).format(amount)
}
@@ -2,8 +2,8 @@ import { ArrowDownRightMini, XCircle } from "@medusajs/icons"
import { import {
Payment as MedusaPayment, Payment as MedusaPayment,
Refund as MedusaRefund, Refund as MedusaRefund,
Order,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { HttpTypes } from "@medusajs/types"
import { import {
Badge, Badge,
Button, Button,
@@ -11,56 +11,60 @@ import {
Heading, Heading,
StatusBadge, StatusBadge,
Text, Text,
toast,
Tooltip, Tooltip,
usePrompt,
} from "@medusajs/ui" } from "@medusajs/ui"
import { format } from "date-fns" import { format } from "date-fns"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useCapturePayment } from "../../../../../hooks/api"
import { formatCurrency } from "../../../../../lib/format-currency"
import { import {
getLocaleAmount, getLocaleAmount,
getStylizedAmount, getStylizedAmount,
} from "../../../../../lib/money-amount-helpers" } from "../../../../../lib/money-amount-helpers"
type OrderPaymentSectionProps = { type OrderPaymentSectionProps = {
order: Order order: HttpTypes.AdminOrder
}
const getPaymentsFromOrder = (order: HttpTypes.AdminOrder) => {
return order.payment_collections
.map((collection: HttpTypes.AdminPaymentCollection) => collection.payments)
.flat(1)
.filter(Boolean)
} }
export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => { export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
const payments = getPaymentsFromOrder(order)
const refunds = payments
.map((payment) => payment?.refunds)
.flat(1)
.filter(Boolean)
return ( return (
<Container className="divide-y divide-dashed p-0"> <Container className="divide-y divide-dashed p-0">
<Header order={order} /> <Header />
<PaymentBreakdown <PaymentBreakdown
payments={order.payments} payments={payments}
refunds={order.refunds} refunds={refunds}
currencyCode={order.currency_code} currencyCode={order.currency_code}
/> />
<Total payments={order.payments} currencyCode={order.currency_code} />
<Total payments={payments} currencyCode={order.currency_code} />
</Container> </Container>
) )
} }
const Header = ({ order }: { order: Order }) => { const Header = () => {
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>
<ActionMenu
groups={[
{
actions: [
{
label: t("orders.payment.refund"),
icon: <ArrowDownRightMini />,
to: `/orders/${order.id}/refund`,
disabled: !hasCapturedPayment,
},
],
},
]}
/>
</div> </div>
) )
} }
@@ -120,6 +124,39 @@ const Payment = ({
currencyCode: string currencyCode: string
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useCapturePayment(payment.id)
const handleCapture = async () => {
const res = await prompt({
title: t("orders.payment.capture"),
description: t("orders.payment.capturePayment", {
amount: formatCurrency(payment.amount, currencyCode),
}),
confirmText: t("actions.confirm"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync(
{ amount: payment.amount },
{
onSuccess: () => {
toast.success(
t("orders.payment.capturePaymentSuccess", {
amount: formatCurrency(payment.amount, currencyCode),
})
)
},
onError: (error) => {
toast.error(error.message)
},
}
)
}
const [status, color] = ( const [status, color] = (
payment.captured_at ? ["Captured", "green"] : ["Pending", "orange"] payment.captured_at ? ["Captured", "green"] : ["Pending", "orange"]
@@ -185,7 +222,8 @@ const Payment = ({
})} })}
</Text> </Text>
</div> </div>
<Button size="small" variant="secondary">
<Button size="small" variant="secondary" onClick={handleCapture}>
{t("orders.payment.capture")} {t("orders.payment.capture")}
</Button> </Button>
</div> </div>
@@ -31,6 +31,10 @@ const DEFAULT_RELATIONS = [
"*fulfillments", "*fulfillments",
"*fulfillments.items", "*fulfillments.items",
"*fulfillments.labels", "*fulfillments.labels",
"*fulfillments.labels",
"*payment_collections",
"*payment_collections.payments",
"*payment_collections.payments.refunds",
] ]
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join( export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(
@@ -6,6 +6,7 @@ import { OrderActivitySection } from "./components/order-activity-section"
import { OrderCustomerSection } from "./components/order-customer-section" import { OrderCustomerSection } from "./components/order-customer-section"
import { OrderFulfillmentSection } from "./components/order-fulfillment-section" import { OrderFulfillmentSection } from "./components/order-fulfillment-section"
import { OrderGeneralSection } from "./components/order-general-section" import { OrderGeneralSection } from "./components/order-general-section"
import { OrderPaymentSection } from "./components/order-payment-section"
import { OrderSummarySection } from "./components/order-summary-section" import { OrderSummarySection } from "./components/order-summary-section"
import { DEFAULT_FIELDS } from "./constants" import { DEFAULT_FIELDS } from "./constants"
import { orderLoader } from "./loader" import { orderLoader } from "./loader"
@@ -51,7 +52,7 @@ export const OrderDetail = () => {
<div className="flex w-full flex-col gap-y-3"> <div className="flex w-full flex-col gap-y-3">
<OrderGeneralSection order={order} /> <OrderGeneralSection order={order} />
<OrderSummarySection order={order} /> <OrderSummarySection order={order} />
{/* <OrderPaymentSection order={order} />*/} <OrderPaymentSection order={order} />
<OrderFulfillmentSection order={order} /> <OrderFulfillmentSection order={order} />
{after.widgets.map((w, i) => { {after.widgets.map((w, i) => {
return ( return (
+3
View File
@@ -8,6 +8,7 @@ import { InventoryItem } from "./inventory-item"
import { Invite } from "./invite" import { Invite } from "./invite"
import { Notification } from "./notification" import { Notification } from "./notification"
import { Order } from "./order" import { Order } from "./order"
import { Payment } from "./payment"
import { PriceList } from "./price-list" import { PriceList } from "./price-list"
import { PricePreference } from "./price-preference" import { PricePreference } from "./price-preference"
import { Product } from "./product" import { Product } from "./product"
@@ -59,6 +60,7 @@ export class Admin {
public productTag: ProductTag public productTag: ProductTag
public user: User public user: User
public currency: Currency public currency: Currency
public payment: Payment
constructor(client: Client) { constructor(client: Client) {
this.invite = new Invite(client) this.invite = new Invite(client)
@@ -90,5 +92,6 @@ export class Admin {
this.productTag = new ProductTag(client) this.productTag = new ProductTag(client)
this.user = new User(client) this.user = new User(client)
this.currency = new Currency(client) this.currency = new Currency(client)
this.payment = new Payment(client)
} }
} }
+27
View File
@@ -0,0 +1,27 @@
import { HttpTypes, SelectParams } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class Payment {
private client: Client
constructor(client: Client) {
this.client = client
}
async capture(
id: string,
body: HttpTypes.AdminCapturePayment,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<{ payment: HttpTypes.AdminPayment }>(
`/admin/payments/${id}/capture`,
{
method: "POST",
headers,
body,
query,
}
)
}
}
+5 -1
View File
@@ -1,3 +1,4 @@
import { AdminPaymentCollection } from "../payment/admin"
import { import {
BaseOrder, BaseOrder,
BaseOrderAddress, BaseOrderAddress,
@@ -6,7 +7,10 @@ import {
BaseOrderShippingMethod, BaseOrderShippingMethod,
} from "./common" } from "./common"
export interface AdminOrder extends BaseOrder {} export interface AdminOrder extends BaseOrder {
payment_collections: AdminPaymentCollection[]
}
export interface AdminOrderLineItem extends BaseOrderLineItem {} export interface AdminOrderLineItem extends BaseOrderLineItem {}
export interface AdminOrderFilters extends BaseOrderFilters {} export interface AdminOrderFilters extends BaseOrderFilters {}
export interface AdminOrderAddress extends BaseOrderAddress {} export interface AdminOrderAddress extends BaseOrderAddress {}
@@ -1,4 +1,5 @@
import { import {
BasePayment,
BasePaymentCollection, BasePaymentCollection,
BasePaymentCollectionFilters, BasePaymentCollectionFilters,
BasePaymentProvider, BasePaymentProvider,
@@ -11,6 +12,7 @@ export interface AdminPaymentProvider extends BasePaymentProvider {
is_enabled: boolean is_enabled: boolean
} }
export interface AdminPayment extends BasePayment {}
export interface AdminPaymentCollection extends BasePaymentCollection {} export interface AdminPaymentCollection extends BasePaymentCollection {}
export interface AdminPaymentSession extends BasePaymentSession {} export interface AdminPaymentSession extends BasePaymentSession {}
@@ -21,3 +23,11 @@ export interface AdminPaymentProviderFilters
export interface AdminPaymentCollectionFilters export interface AdminPaymentCollectionFilters
extends BasePaymentCollectionFilters {} extends BasePaymentCollectionFilters {}
export interface AdminPaymentSessionFilters extends BasePaymentSessionFilters {} export interface AdminPaymentSessionFilters extends BasePaymentSessionFilters {}
export interface AdminCapturePayment {
amount?: number
}
export interface AdminPaymentResponse {
payment: AdminPayment
}