feat(dashboard,types): add credit lines + loyalty changes (#11885)

* feat(dashboard,types): add credit lines + loyalty changes

* chore: fix types

* chore: fix specs

* chore: use correct plugin name

* chore: use new currency input

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2025-06-12 20:12:03 +02:00
committed by GitHub
co-authored by Oli Juhl
parent 65f9333501
commit 44d1d18689
21 changed files with 883 additions and 113 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/dashboard": patch
"@medusajs/types": patch
---
feat(dashboard,types): add credit lines + loyalty changes
@@ -15,6 +15,7 @@ export * from "./notification"
export * from "./orders" export * from "./orders"
export * from "./payment-collections" export * from "./payment-collections"
export * from "./payments" export * from "./payments"
export * from "./plugins"
export * from "./price-lists" export * from "./price-lists"
export * from "./product-types" export * from "./product-types"
export * from "./product-variants" export * from "./product-variants"
@@ -1,5 +1,5 @@
import { FetchError } from "@medusajs/js-sdk" import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types" import { CreateOrderCreditLineDTO, HttpTypes } from "@medusajs/types"
import { import {
QueryKey, QueryKey,
useMutation, useMutation,
@@ -354,3 +354,28 @@ export const useCancelOrderTransfer = (
...options, ...options,
}) })
} }
export const useCreateOrderCreditLine = (
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderResponse,
FetchError,
Omit<CreateOrderCreditLineDTO, "order_id">
>
) => {
return useMutation({
mutationFn: (payload) => sdk.admin.order.createCreditLine(orderId, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -0,0 +1,28 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const PLUGINS_QUERY_KEY = "plugins" as const
export const pluginsQueryKeys = queryKeysFactory(PLUGINS_QUERY_KEY)
export const usePlugins = (
options?: Omit<
UseQueryOptions<
any,
FetchError,
HttpTypes.AdminPluginsListResponse,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.plugin.list(),
queryKey: pluginsQueryKeys.list(),
...options,
})
return { ...data, ...rest }
}
@@ -3783,6 +3783,78 @@
"orders": { "orders": {
"type": "object", "type": "object",
"properties": { "properties": {
"giftCardsStoreCreditLines": {
"type": "string"
},
"creditLines": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"total": {
"type": "string"
},
"creditOrDebit": {
"type": "string"
},
"createCreditLine": {
"type": "string"
},
"createCreditLineSuccess": {
"type": "string"
},
"createCreditLineError": {
"type": "string"
},
"createCreditLineDescription": {
"type": "string"
},
"operation": {
"type": "string"
},
"credit": {
"type": "string"
},
"debit": {
"type": "string"
},
"debitDescription": {
"type": "string"
},
"creditDescription": {
"type": "string"
}
}
},
"balanceSettlement": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"settlementType": {
"type": "string"
},
"settlementTypes": {
"type": "object",
"properties": {
"paymentMethod": {
"type": "string"
},
"paymentMethodDescription": {
"type": "string"
},
"creditLine": {
"type": "string"
},
"creditLineDescription": {
"type": "string"
}
}
}
}
},
"domain": { "domain": {
"type": "string" "type": "string"
}, },
@@ -3954,6 +4026,9 @@
"totalPaidByCustomer": { "totalPaidByCustomer": {
"type": "string" "type": "string"
}, },
"totalStoreCreditRefunds": {
"type": "string"
},
"capture": { "capture": {
"type": "string" "type": "string"
}, },
@@ -4055,6 +4130,7 @@
"title", "title",
"isReadyToBeCaptured", "isReadyToBeCaptured",
"totalPaidByCustomer", "totalPaidByCustomer",
"totalStoreCreditRefunds",
"capture", "capture",
"capture_short", "capture_short",
"refund", "refund",
@@ -10601,6 +10677,12 @@
"amount": { "amount": {
"type": "string" "type": "string"
}, },
"reference": {
"type": "string"
},
"reference_id": {
"type": "string"
},
"refundAmount": { "refundAmount": {
"type": "string" "type": "string"
}, },
@@ -10859,6 +10941,9 @@
"paidTotal": { "paidTotal": {
"type": "string" "type": "string"
}, },
"creditTotal": {
"type": "string"
},
"totalExclTax": { "totalExclTax": {
"type": "string" "type": "string"
}, },
@@ -1010,6 +1010,31 @@
} }
}, },
"orders": { "orders": {
"giftCardsStoreCreditLines": "Gift cards & credit lines",
"creditLines": {
"title": "Credit lines",
"total": "Sum of all credit lines",
"creditOrDebit": "Credit / Debit",
"createCreditLine": "Create credit line",
"createCreditLineSuccess": "Credit line created successfully",
"createCreditLineError": "Error creating credit line",
"createCreditLineDescription": "Create a credit line for amount {{amount}}",
"operation": "Operation",
"credit": "Credit",
"creditDescription": "Adds a positive sum to the order",
"debit": "Debit",
"debitDescription": "Subtracts a negative sum from the order"
},
"balanceSettlement": {
"title": "Balance settlement",
"settlementType": "Settlement type",
"settlementTypes": {
"paymentMethod": "Payment method",
"paymentMethodDescription": "Refund amount to the payment method",
"creditLine": "Store credit",
"creditLineDescription": "Refund amount as store credit"
}
},
"domain": "Orders", "domain": "Orders",
"claim": "Claim", "claim": "Claim",
"exchange": "Exchange", "exchange": "Exchange",
@@ -1056,6 +1081,7 @@
"title": "Payments", "title": "Payments",
"isReadyToBeCaptured": "Payment <0/> is ready to be captured.", "isReadyToBeCaptured": "Payment <0/> is ready to be captured.",
"totalPaidByCustomer": "Total paid by customer", "totalPaidByCustomer": "Total paid by customer",
"totalStoreCreditRefunds": "Total store credit refunds",
"capture": "Capture payment", "capture": "Capture payment",
"capture_short": "Capture", "capture_short": "Capture",
"refund": "Refund", "refund": "Refund",
@@ -2847,6 +2873,8 @@
}, },
"fields": { "fields": {
"amount": "Amount", "amount": "Amount",
"reference": "Reference",
"reference_id": "Reference ID",
"refundAmount": "Refund amount", "refundAmount": "Refund amount",
"name": "Name", "name": "Name",
"default": "Default", "default": "Default",
@@ -2932,7 +2960,8 @@
"orders": "Orders", "orders": "Orders",
"account": "Account", "account": "Account",
"total": "Order Total", "total": "Order Total",
"paidTotal": "Total captured", "paidTotal": "Paid Total",
"creditTotal": "Credit Lines Total",
"totalExclTax": "Total excl. tax", "totalExclTax": "Total excl. tax",
"subtotal": "Subtotal", "subtotal": "Subtotal",
"shipping": "Shipping", "shipping": "Shipping",
@@ -0,0 +1,8 @@
import { OrderCreditLineDTO } from "@medusajs/types"
export const getTotalCreditLines = (creditLines: OrderCreditLineDTO[]) =>
creditLines.reduce((acc, creditLine) => {
acc = acc + (creditLine.amount as number)
return acc
}, 0)
@@ -0,0 +1,8 @@
import { HttpTypes } from "@medusajs/types"
export const getPaymentsFromOrder = (order: HttpTypes.AdminOrder) => {
return order.payment_collections
.map((collection: HttpTypes.AdminPaymentCollection) => collection.payments)
.flat(1)
.filter(Boolean) as HttpTypes.AdminPayment[]
}
@@ -0,0 +1,7 @@
import { HttpTypes } from "@medusajs/types"
export const LOYALTY_PLUGIN_NAME = "@medusajs/loyalty-plugin"
export const getLoyaltyPlugin = (plugins: HttpTypes.AdminPlugin[]) => {
return plugins?.find((plugin) => plugin.name === LOYALTY_PLUGIN_NAME)
}
@@ -0,0 +1 @@
export * from "./order-balance-settlement-form"
@@ -0,0 +1,398 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminOrder, AdminPayment } from "@medusajs/types"
import {
Button,
clx,
CurrencyInput,
Divider,
Input,
Label,
RadioGroup,
Select,
Textarea,
toast,
} from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
import { formatValue } from "react-currency-input-field"
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 { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import {
useCreateOrderCreditLine,
useRefundPayment,
} from "../../../../../hooks/api"
import { currencies } from "../../../../../lib/data/currencies"
import { formatCurrency } from "../../../../../lib/format-currency"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
const OrderBalanceSettlementSchema = zod.object({
settlement_type: zod.enum(["credit_line", "refund"]),
refund: zod
.object({
amount: zod.string().or(zod.number()).optional(),
note: zod.string().optional(),
})
.optional(),
credit_line: zod
.object({
amount: zod.string().or(zod.number()).optional(),
reference: zod.string().optional(),
reference_id: zod.string().optional(),
note: zod.string().optional(),
})
.optional(),
})
export const OrderBalanceSettlementForm = ({
order,
}: {
order: AdminOrder
}) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [activePayment, setActivePayment] = useState<AdminPayment | null>(null)
const payments = getPaymentsFromOrder(order)
const pendingDifference = order.summary.pending_difference * -1
const form = useForm<zod.infer<typeof OrderBalanceSettlementSchema>>({
defaultValues: {
settlement_type: "refund",
refund: {
amount: 0,
},
credit_line: {
amount: 0,
},
},
resolver: zodResolver(OrderBalanceSettlementSchema),
})
const { mutateAsync: createCreditLine, isPending: isCreditLinePending } =
useCreateOrderCreditLine(order.id)
const { mutateAsync: createRefund, isPending: isRefundPending } =
useRefundPayment(order.id, activePayment?.id!)
const settlementType = form.watch("settlement_type")
const handleSubmit = form.handleSubmit(async (data) => {
if (data.settlement_type === "credit_line") {
await createCreditLine(
{
amount: parseFloat(data.credit_line!.amount! as string) * -1,
reference: data.credit_line!.reference ?? "order",
reference_id: data.credit_line!.reference_id ?? order.id,
},
{
onSuccess: () => {
toast.success(t("orders.creditLines.createCreditLineSuccess"))
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
}
if (data.settlement_type === "refund") {
await createRefund(
{
amount: parseFloat(data.refund!.amount! as string),
note: data.refund!.note,
},
{
onSuccess: () => {
toast.success(
t("orders.payment.refundPaymentSuccess", {
amount: formatCurrency(
parseFloat(data.refund!.amount! as string),
order.currency_code!
),
})
)
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
}
})
const currency = useMemo(
() => currencies[order.currency_code.toUpperCase()],
[order.currency_code]
)
useEffect(() => {
form.clearErrors()
const minimum = activePayment?.amount
? Math.min(pendingDifference, activePayment.amount)
: pendingDifference
if (settlementType === "refund") {
form.setValue("refund.amount", activePayment ? minimum : 0)
}
if (settlementType === "credit_line") {
form.setValue("credit_line.amount", minimum)
}
}, [settlementType, activePayment, pendingDifference, form])
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
onSubmit={handleSubmit}
className="flex size-full flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex-1 overflow-auto">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-4">
<Label className="txt-compact-small font-sans font-medium">
{t("orders.balanceSettlement.settlementType")}
</Label>
<RadioGroup
className="flex flex-col gap-y-3"
value={settlementType}
onValueChange={(value: "credit_line" | "refund") =>
form.setValue("settlement_type", value)
}
>
<RadioGroup.ChoiceBox
value={"refund"}
description={t(
"orders.balanceSettlement.settlementTypes.paymentMethodDescription"
)}
label={t(
"orders.balanceSettlement.settlementTypes.paymentMethod"
)}
className={clx("basis-1/2")}
/>
<RadioGroup.ChoiceBox
value={"credit_line"}
description={t(
"orders.balanceSettlement.settlementTypes.creditLineDescription"
)}
label={t(
"orders.balanceSettlement.settlementTypes.creditLine"
)}
className={clx("basis-1/2")}
/>
</RadioGroup>
</div>
<Divider />
{settlementType === "refund" && (
<>
<div className="flex flex-col gap-y-4">
<Select
onValueChange={(value) => {
setActivePayment(payments.find((p) => p.id === value)!)
}}
>
<Label className="txt-compact-small mb-[-6px] font-sans font-medium">
{t("orders.payment.selectPaymentToRefund")}
</Label>
<Select.Trigger>
<Select.Value
placeholder={t("orders.payment.selectPaymentToRefund")}
/>
</Select.Trigger>
<Select.Content>
{payments.map((payment) => {
const totalRefunded =
payment.refunds?.reduce(
(acc, next) => next.amount + acc,
0
) ?? 0
return (
<Select.Item
value={payment!.id}
key={payment.id}
disabled={
!!payment.canceled_at ||
totalRefunded >= payment.amount
}
>
<span>
{getLocaleAmount(
payment.amount as number,
payment.currency_code
)}
{" - "}
</span>
<span>{payment.provider_id}</span>
<span> - ({payment.id.replace("pay_", "")})</span>
</Select.Item>
)
})}
</Select.Content>
</Select>
</div>
<Form.Field
control={form.control}
name="refund.amount"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.amount")}</Form.Label>
<Form.Control>
<CurrencyInput
{...field}
min={0}
placeholder={formatValue({
value: "0",
decimalScale: currency.decimal_digits,
})}
decimalScale={currency.decimal_digits}
symbol={currency.symbol_native}
code={currency.code}
value={field.value}
onValueChange={(_value, _name, values) =>
onChange(values?.value ? values?.value : "")
}
autoFocus
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`refund.note`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.note")}</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</>
)}
{settlementType === "credit_line" && (
<>
<Form.Field
control={form.control}
name="credit_line.amount"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.amount")}</Form.Label>
<Form.Control>
<CurrencyInput
{...field}
min={0}
placeholder={formatValue({
value: "0",
decimalScale: currency.decimal_digits,
})}
decimalScale={currency.decimal_digits}
symbol={currency.symbol_native}
code={currency.code}
value={field.value}
onValueChange={(_value, _name, values) =>
onChange(values?.value ? values?.value : "")
}
autoFocus
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="credit_line.reference"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.reference")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="credit_line.reference_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.reference_id")}</Form.Label>
<Form.Control>
<Input {...field} />
</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={isCreditLinePending || isRefundPending}
type="submit"
variant="primary"
size="small"
disabled={!!Object.keys(form.formState.errors || {}).length}
>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export * from "./components/order-balance-settlement-form"
@@ -9,6 +9,7 @@ import {
toast, toast,
} from "@medusajs/ui" } from "@medusajs/ui"
import { useEffect, useMemo } from "react" import { useEffect, useMemo } from "react"
import { formatValue } from "react-currency-input-field"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate, useSearchParams } from "react-router-dom"
@@ -19,26 +20,20 @@ import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useRefundPayment } from "../../../../../hooks/api" import { useRefundPayment } from "../../../../../hooks/api"
import { currencies } from "../../../../../lib/data/currencies" import { currencies } from "../../../../../lib/data/currencies"
import { formatCurrency } from "../../../../../lib/format-currency" import { formatCurrency } from "../../../../../lib/format-currency"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../order-detail/components/order-payment-section"
import { formatValue } from "react-currency-input-field"
import { formatProvider } from "../../../../../lib/format-provider" import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
type CreateRefundFormProps = { type CreateRefundFormProps = {
order: HttpTypes.AdminOrder order: HttpTypes.AdminOrder
refundReasons: HttpTypes.AdminRefundReason[]
} }
const CreateRefundSchema = zod.object({ const CreateRefundSchema = zod.object({
amount: zod.string().or(zod.number()), amount: zod.string().or(zod.number()),
refund_reason_id: zod.string().nullish(),
note: zod.string().optional(), note: zod.string().optional(),
}) })
export const CreateRefundForm = ({ export const CreateRefundForm = ({ order }: CreateRefundFormProps) => {
order,
refundReasons,
}: CreateRefundFormProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
const navigate = useNavigate() const navigate = useNavigate()
@@ -81,14 +76,16 @@ export const CreateRefundForm = ({
await mutateAsync( await mutateAsync(
{ {
amount: parseFloat(data.amount as string), amount: parseFloat(data.amount as string),
refund_reason_id: data.refund_reason_id,
note: data.note, note: data.note,
}, },
{ {
onSuccess: () => { onSuccess: () => {
toast.success( toast.success(
t("orders.payment.refundPaymentSuccess", { t("orders.payment.refundPaymentSuccess", {
amount: formatCurrency(data.amount, payment?.currency_code!), amount: formatCurrency(
data.amount as number,
payment?.currency_code!
),
}) })
) )
@@ -196,30 +193,6 @@ export const CreateRefundForm = ({
}} }}
/> />
{/* TODO: Bring this back when we have a refund reason management UI */}
{/* <Form.Field
control={form.control}
name="refund_reason_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.refundReason")}</Form.Label>
<Form.Control>
<Combobox
{...field}
options={refundReasons.map((pp) => ({
label: upperCaseFirst(pp.label),
value: pp.id,
}))}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/> */}
<Form.Field <Form.Field
control={form.control} control={form.control}
name={`note`} name={`note`}
@@ -2,7 +2,9 @@ import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/modals" import { RouteDrawer } from "../../../components/modals"
import { useOrder, useRefundReasons } from "../../../hooks/api" import { useOrder, usePlugins } from "../../../hooks/api"
import { getLoyaltyPlugin } from "../../../lib/plugins"
import { OrderBalanceSettlementForm } from "../order-balance-settlement"
import { DEFAULT_FIELDS } from "../order-detail/constants" import { DEFAULT_FIELDS } from "../order-detail/constants"
import { CreateRefundForm } from "./components/create-refund-form" import { CreateRefundForm } from "./components/create-refund-form"
@@ -13,16 +15,8 @@ export const OrderCreateRefund = () => {
fields: DEFAULT_FIELDS, fields: DEFAULT_FIELDS,
}) })
const { const { plugins = [] } = usePlugins()
refund_reasons: refundReasons, const loyaltyPlugin = getLoyaltyPlugin(plugins)
isLoading: isRefundReasonsLoading,
isError: isRefundReasonsError,
error: refundReasonsError,
} = useRefundReasons()
if (isRefundReasonsError) {
throw refundReasonsError
}
return ( return (
<RouteDrawer> <RouteDrawer>
@@ -30,9 +24,8 @@ export const OrderCreateRefund = () => {
<Heading>{t("orders.payment.createRefund")}</Heading> <Heading>{t("orders.payment.createRefund")}</Heading>
</RouteDrawer.Header> </RouteDrawer.Header>
{order && !isRefundReasonsLoading && refundReasons && ( {order && !loyaltyPlugin && <CreateRefundForm order={order} />}
<CreateRefundForm order={order} refundReasons={refundReasons} /> {order && loyaltyPlugin && <OrderBalanceSettlementForm order={order} />}
)}
</RouteDrawer> </RouteDrawer>
) )
} }
@@ -30,7 +30,7 @@ import { useCancelReturn, useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date" import { useDate } from "../../../../../hooks/use-date"
import { getFormattedAddress } from "../../../../../lib/addresses" import { getFormattedAddress } from "../../../../../lib/addresses"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../order-payment-section" import { getPaymentsFromOrder } from "../../../../../lib/orders"
import ActivityItems from "./activity-items" import ActivityItems from "./activity-items"
import ChangeDetailsTooltip from "./change-details-tooltip" import ChangeDetailsTooltip from "./change-details-tooltip"
@@ -392,12 +392,12 @@ const useActivityItems = (order: AdminOrder): Activity[] => {
edit.status === "requested" edit.status === "requested"
? edit.requested_at ? edit.requested_at
: edit.status === "confirmed" : edit.status === "confirmed"
? edit.confirmed_at ? edit.confirmed_at
: edit.status === "declined" : edit.status === "declined"
? edit.declined_at ? edit.declined_at
: edit.status === "canceled" : edit.status === "canceled"
? edit.canceled_at ? edit.canceled_at
: edit.created_at, : edit.created_at,
children: isConfirmed ? <OrderEditBody edit={edit} /> : null, children: isConfirmed ? <OrderEditBody edit={edit} /> : null,
}) })
} }
@@ -1,3 +1,4 @@
import { OrderCreditLineDTO } from "@medusajs/framework/types"
import { ArrowDownRightMini, DocumentText, XCircle } from "@medusajs/icons" import { ArrowDownRightMini, DocumentText, XCircle } from "@medusajs/icons"
import { AdminOrder, AdminPayment, HttpTypes } from "@medusajs/types" import { AdminOrder, AdminPayment, HttpTypes } from "@medusajs/types"
import { import {
@@ -22,20 +23,19 @@ import {
getStylizedAmount, getStylizedAmount,
} from "../../../../../lib/money-amount-helpers" } from "../../../../../lib/money-amount-helpers"
import { getOrderPaymentStatus } from "../../../../../lib/order-helpers" import { getOrderPaymentStatus } from "../../../../../lib/order-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
import { getTotalCaptured, getTotalPending } from "../../../../../lib/payment" import { getTotalCaptured, getTotalPending } from "../../../../../lib/payment"
import { getLoyaltyPlugin } from "../../../../../lib/plugins"
type OrderPaymentSectionProps = { type OrderPaymentSectionProps = {
order: HttpTypes.AdminOrder order: HttpTypes.AdminOrder
plugins: HttpTypes.AdminPlugin[]
} }
export const getPaymentsFromOrder = (order: HttpTypes.AdminOrder) => { export const OrderPaymentSection = ({
return order.payment_collections order,
.map((collection: HttpTypes.AdminPaymentCollection) => collection.payments) plugins,
.flat(1) }: OrderPaymentSectionProps) => {
.filter(Boolean) as HttpTypes.AdminPayment[]
}
export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
const payments = getPaymentsFromOrder(order) const payments = getPaymentsFromOrder(order)
const refunds = payments const refunds = payments
@@ -49,6 +49,7 @@ export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
<PaymentBreakdown <PaymentBreakdown
order={order} order={order}
plugins={plugins}
payments={payments} payments={payments}
refunds={refunds} refunds={refunds}
currencyCode={order.currency_code} currencyCode={order.currency_code}
@@ -59,7 +60,7 @@ export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
) )
} }
const Header = ({ order }) => { const Header = ({ order }: { order: HttpTypes.AdminOrder }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { label, color } = getOrderPaymentStatus(t, order.payment_status) const { label, color } = getOrderPaymentStatus(t, order.payment_status)
@@ -279,23 +280,94 @@ const Payment = ({
) )
} }
const CreditLine = ({
creditLine,
currencyCode,
plugins,
}: {
creditLine: OrderCreditLineDTO
currencyCode: string
plugins: HttpTypes.AdminPlugin[]
}) => {
const loyaltyPlugin = getLoyaltyPlugin(plugins)
if (!loyaltyPlugin) {
return null
}
const prettyReference = creditLine.reference
?.split("_")
.join(" ")
.split("-")
.join(" ")
const prettyReferenceId = creditLine.reference_id ? (
<DisplayId id={creditLine.reference_id} />
) : null
return (
<div className="divide-y divide-dashed">
<div className="text-ui-fg-subtle grid grid-cols-[1fr_1fr_20px] items-center gap-x-4 px-6 py-4 sm:grid-cols-[1fr_1fr_1fr_20px]">
<div className="w-full min-w-[60px] overflow-hidden">
<Text
size="small"
leading="compact"
weight="plus"
className="truncate"
>
{loyaltyPlugin ? (
<Text size="small" leading="compact" weight="plus">
Store credit refund
</Text>
) : (
<DisplayId id={creditLine.id} />
)}
</Text>
<Text size="small" leading="compact">
{format(
new Date(creditLine.created_at as string),
"dd MMM, yyyy, HH:mm:ss"
)}
</Text>
</div>
<div className="hidden items-center justify-end sm:flex">
<Text size="small" leading="compact" className="capitalize">
{prettyReference} ({prettyReferenceId})
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{getLocaleAmount(creditLine.amount as number, currencyCode)}
</Text>
</div>
</div>
</div>
)
}
const PaymentBreakdown = ({ const PaymentBreakdown = ({
order, order,
payments, payments,
refunds, refunds,
currencyCode, currencyCode,
plugins,
}: { }: {
order: HttpTypes.AdminOrder order: HttpTypes.AdminOrder
payments: HttpTypes.AdminPayment[] payments: HttpTypes.AdminPayment[]
refunds: HttpTypes.AdminRefund[] refunds: HttpTypes.AdminRefund[]
currencyCode: string currencyCode: string
plugins: HttpTypes.AdminPlugin[]
}) => { }) => {
/** /**
* Refunds that are not associated with a payment. * Refunds that are not associated with a payment.
*/ */
const orderRefunds = refunds.filter((refund) => refund.payment_id === null) const orderRefunds = refunds.filter((refund) => refund.payment_id === null)
const creditLines = order.credit_lines ?? []
const creditLineRefunds = creditLines.filter(
(creditLine) => (creditLine.amount as number) < 0
)
const entries = [...orderRefunds, ...payments] const entries = [...orderRefunds, ...payments, ...creditLineRefunds]
.sort((a, b) => { .sort((a, b) => {
return ( return (
new Date(a.created_at as string).getTime() - new Date(a.created_at as string).getTime() -
@@ -303,13 +375,20 @@ const PaymentBreakdown = ({
) )
}) })
.map((entry) => { .map((entry) => {
return { let type = entry.id.startsWith("pay_") ? "payment" : "refund"
event: entry,
type: entry.id.startsWith("pay_") ? "payment" : "refund", if (entry.id.startsWith("ordcl_")) {
type = "credit_line_refund"
} }
return { event: entry, type }
}) as ( }) as (
| { type: "payment"; event: HttpTypes.AdminPayment } | { type: "payment"; event: HttpTypes.AdminPayment }
| { type: "refund"; event: HttpTypes.AdminRefund } | { type: "refund"; event: HttpTypes.AdminRefund }
| {
type: "credit_line_refund"
event: OrderCreditLineDTO
}
)[] )[]
return ( return (
@@ -336,6 +415,15 @@ const PaymentBreakdown = ({
currencyCode={currencyCode} currencyCode={currencyCode}
/> />
) )
case "credit_line_refund":
return (
<CreditLine
key={event.id}
creditLine={event}
currencyCode={currencyCode}
plugins={plugins}
/>
)
} }
})} })}
</div> </div>
@@ -347,8 +435,8 @@ const Total = ({ order }: { order: AdminOrder }) => {
const totalPending = getTotalPending(order.payment_collections) const totalPending = getTotalPending(order.payment_collections)
return ( return (
<div> <div className="flex flex-col gap-y-4 px-6 py-4">
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between">
<Text size="small" weight="plus" leading="compact"> <Text size="small" weight="plus" leading="compact">
{t("orders.payment.totalPaidByCustomer")} {t("orders.payment.totalPaidByCustomer")}
</Text> </Text>
@@ -362,7 +450,7 @@ const Total = ({ order }: { order: AdminOrder }) => {
</div> </div>
{order.status !== "canceled" && totalPending > 0 && ( {order.status !== "canceled" && totalPending > 0 && (
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between">
<Text size="small" weight="plus" leading="compact"> <Text size="small" weight="plus" leading="compact">
Total pending Total pending
</Text> </Text>
@@ -1,6 +1,6 @@
import { ReactNode, useMemo, useState } from "react" import { ReactNode, useMemo, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom" import { Link } from "react-router-dom"
import { import {
ArrowDownRightMini, ArrowDownRightMini,
@@ -19,6 +19,7 @@ import {
AdminOrderLineItem, AdminOrderLineItem,
AdminOrderPreview, AdminOrderPreview,
AdminPaymentCollection, AdminPaymentCollection,
AdminPlugin,
AdminRegion, AdminRegion,
AdminReturn, AdminReturn,
} from "@medusajs/types" } from "@medusajs/types"
@@ -37,7 +38,9 @@ import {
} from "@medusajs/ui" } from "@medusajs/ui"
import { AdminReservation } from "@medusajs/types/src/http" import { AdminReservation } from "@medusajs/types/src/http"
import { format } from "date-fns"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import DisplayId from "../../../../../components/common/display-id/display-id"
import { Thumbnail } from "../../../../../components/common/thumbnail" import { Thumbnail } from "../../../../../components/common/thumbnail"
import { useClaims } from "../../../../../hooks/api/claims" import { useClaims } from "../../../../../hooks/api/claims"
import { useExchanges } from "../../../../../hooks/api/exchanges" import { useExchanges } from "../../../../../hooks/api/exchanges"
@@ -46,6 +49,7 @@ import { useMarkPaymentCollectionAsPaid } from "../../../../../hooks/api/payment
import { useReservationItems } from "../../../../../hooks/api/reservations" import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReturns } from "../../../../../hooks/api/returns" import { useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date" import { useDate } from "../../../../../hooks/use-date"
import { getTotalCreditLines } from "../../../../../lib/credit-line"
import { formatCurrency } from "../../../../../lib/format-currency" import { formatCurrency } from "../../../../../lib/format-currency"
import { import {
getLocaleAmount, getLocaleAmount,
@@ -53,6 +57,7 @@ import {
isAmountLessThenRoundingError, isAmountLessThenRoundingError,
} from "../../../../../lib/money-amount-helpers" } from "../../../../../lib/money-amount-helpers"
import { getTotalCaptured } from "../../../../../lib/payment" import { getTotalCaptured } from "../../../../../lib/payment"
import { getLoyaltyPlugin } from "../../../../../lib/plugins"
import { getReturnableQuantity } from "../../../../../lib/rma" import { getReturnableQuantity } from "../../../../../lib/rma"
import { CopyPaymentLink } from "../copy-payment-link/copy-payment-link" import { CopyPaymentLink } from "../copy-payment-link/copy-payment-link"
import ReturnInfoPopover from "./return-info-popover" import ReturnInfoPopover from "./return-info-popover"
@@ -60,9 +65,13 @@ import ShippingInfoPopover from "./shipping-info-popover"
type OrderSummarySectionProps = { type OrderSummarySectionProps = {
order: AdminOrder order: AdminOrder
plugins: AdminPlugin[]
} }
export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => { export const OrderSummarySection = ({
order,
plugins,
}: OrderSummarySectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
@@ -133,8 +142,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
const showPayment = const showPayment =
unpaidPaymentCollection && pendingDifference > 0 && isAmountSignificant unpaidPaymentCollection && pendingDifference > 0 && isAmountSignificant
const showRefund = const showRefund = pendingDifference < 0 && isAmountSignificant
unpaidPaymentCollection && pendingDifference < 0 && isAmountSignificant
const handleMarkAsPaid = async ( const handleMarkAsPaid = async (
paymentCollection: AdminPaymentCollection paymentCollection: AdminPaymentCollection
@@ -181,6 +189,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
<Header order={order} orderPreview={orderPreview} /> <Header order={order} orderPreview={orderPreview} />
<ItemBreakdown order={order} reservations={reservations!} /> <ItemBreakdown order={order} reservations={reservations!} />
<CostBreakdown order={order} /> <CostBreakdown order={order} />
<CreditLinesBreakdown order={order} plugins={plugins} />
<Total order={order} /> <Total order={order} />
{(showAllocateButton || showReturns || showPayment || showRefund) && ( {(showAllocateButton || showReturns || showPayment || showRefund) && (
@@ -398,12 +407,7 @@ const Item = ({
<div className="flex items-start gap-x-4"> <div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} /> <Thumbnail src={item.thumbnail} />
<div> <div>
<Text <Text size="small" leading="compact" className="text-ui-fg-base">
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.title} {item.title}
</Text> </Text>
@@ -741,6 +745,109 @@ const CostBreakdown = ({
) )
} }
const CreditLinesBreakdown = ({
order,
plugins,
}: {
order: AdminOrder & { region?: AdminRegion | null }
plugins: AdminPlugin[]
}) => {
const { t } = useTranslation()
const [isCreditLinesOpen, setIsCreditLinesOpen] = useState(false)
const creditLines = order.credit_lines ?? []
const loyaltyPlugin = getLoyaltyPlugin(plugins)
if (creditLines.length === 0) {
return null
}
return (
<div className="text-ui-fg-subtle flex flex-col">
<>
<div
onClick={() => setIsCreditLinesOpen((o) => !o)}
className="bg-ui-bg-component flex cursor-pointer items-center justify-between border border-dashed px-6 py-4"
>
<div className="flex items-center gap-2">
<TriangleDownMini
style={{
transform: `rotate(${isCreditLinesOpen ? 0 : -90}deg)`,
}}
/>
<span className="text-ui-fg-muted txt-small select-none">
{loyaltyPlugin
? t("orders.giftCardsStoreCreditLines")
: t("orders.creditLines.title")}
</span>
</div>
<div>
<Text size="small" leading="compact">
{getLocaleAmount(order.credit_line_total, order.currency_code)}
</Text>
</div>
</div>
{isCreditLinesOpen && (
<div className="flex flex-col">
{creditLines.map((creditLine) => {
const prettyReference = creditLine.reference
?.split("_")
.join(" ")
.split("-")
.join(" ")
const prettyReferenceId = creditLine.reference_id ? (
<DisplayId id={creditLine.reference_id} />
) : null
return (
<div
className="text-ui-fg-subtle grid grid-cols-[1fr_1fr_1fr] items-center px-6 py-4 py-4 sm:grid-cols-[1fr_1fr_1fr]"
key={creditLine.id}
>
<div className="w-full min-w-[60px] overflow-hidden">
<Text
size="small"
leading="compact"
weight="plus"
className="truncate"
>
<DisplayId id={creditLine.id} />
</Text>
<Text size="small" leading="compact">
{format(
new Date(creditLine.created_at),
"dd MMM, yyyy, HH:mm:ss"
)}
</Text>
</div>
<div className="hidden items-center justify-end gap-x-2 sm:flex">
<Text size="small" leading="compact" className="capitalize">
{prettyReference} ({prettyReferenceId})
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{getLocaleAmount(
creditLine.amount as number,
order.currency_code
)}
</Text>
</div>
</div>
)
})}
</div>
)}
</>
</div>
)
}
const InventoryKitBreakdown = ({ item }: { item: AdminOrderLineItem }) => { const InventoryKitBreakdown = ({ item }: { item: AdminOrderLineItem }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1028,39 +1135,19 @@ const Total = ({ order }: { order: AdminOrder }) => {
return ( return (
<div className=" flex flex-col gap-y-2 px-6 py-4"> <div className=" flex flex-col gap-y-2 px-6 py-4">
<div className="text-ui-fg-base flex items-center justify-between"> <div className="text-ui-fg-base flex items-center justify-between">
<Text <Text className="text-ui-fg-subtle" size="small" leading="compact">
weight="plus"
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
{t("fields.total")} {t("fields.total")}
</Text> </Text>
<Text <Text className="text-ui-fg-subtle" size="small" leading="compact">
weight="plus" {getStylizedAmount(order.original_total, order.currency_code)}
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
{getStylizedAmount(order.total, order.currency_code)}
</Text> </Text>
</div> </div>
<div className="text-ui-fg-base flex items-center justify-between"> <div className="text-ui-fg-base flex items-center justify-between">
<Text <Text className="text-ui-fg-subtle" size="small" leading="compact">
weight="plus"
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
{t("fields.paidTotal")} {t("fields.paidTotal")}
</Text> </Text>
<Text <Text className="text-ui-fg-subtle" size="small" leading="compact">
weight="plus"
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
{getStylizedAmount( {getStylizedAmount(
getTotalCaptured(order.payment_collections || []), getTotalCaptured(order.payment_collections || []),
order.currency_code order.currency_code
@@ -1068,12 +1155,24 @@ const Total = ({ order }: { order: AdminOrder }) => {
</Text> </Text>
</div> </div>
<div className="text-ui-fg-base flex items-center justify-between">
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{t("fields.creditTotal")}
</Text>
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{getStylizedAmount(
getTotalCreditLines(order.credit_lines ?? []),
order.currency_code
)}
</Text>
</div>
<div className="text-ui-fg-base flex items-center justify-between"> <div className="text-ui-fg-base flex items-center justify-between">
<Text <Text
className="text-ui-fg-subtle text-semibold" className="text-ui-fg-subtle text-semibold"
size="small" size="small"
leading="compact" leading="compact"
weight="plus"
> >
{t("orders.returns.outstandingAmount")} {t("orders.returns.outstandingAmount")}
</Text> </Text>
@@ -1081,7 +1180,6 @@ const Total = ({ order }: { order: AdminOrder }) => {
className="text-ui-fg-subtle text-bold" className="text-ui-fg-subtle text-bold"
size="small" size="small"
leading="compact" leading="compact"
weight="plus"
> >
{getStylizedAmount( {getStylizedAmount(
order.summary.pending_difference || 0, order.summary.pending_difference || 0,
@@ -9,8 +9,10 @@ const DEFAULT_PROPERTIES = [
"metadata", "metadata",
// --- TOTALS --- // --- TOTALS ---
"total", "total",
"credit_line_total",
"item_total", "item_total",
"shipping_subtotal", "shipping_subtotal",
"original_total",
"subtotal", "subtotal",
"discount_total", "discount_total",
"discount_subtotal", "discount_subtotal",
@@ -36,6 +38,7 @@ const DEFAULT_RELATIONS = [
"*sales_channel", "*sales_channel",
"*promotion", "*promotion",
"*shipping_methods", "*shipping_methods",
"*credit_lines",
"*fulfillments", "*fulfillments",
"+fulfillments.shipping_option.service_zone.fulfillment_set.type", "+fulfillments.shipping_option.service_zone.fulfillment_set.type",
"*fulfillments.items", "*fulfillments.items",
@@ -3,6 +3,7 @@ import { useLoaderData, useParams } from "react-router-dom"
import { TwoColumnPageSkeleton } from "../../../components/common/skeleton" import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
import { TwoColumnPage } from "../../../components/layout/pages" import { TwoColumnPage } from "../../../components/layout/pages"
import { useOrder, useOrderPreview } from "../../../hooks/api/orders" import { useOrder, useOrderPreview } from "../../../hooks/api/orders"
import { usePlugins } from "../../../hooks/api/plugins"
import { useExtension } from "../../../providers/extension-provider" import { useExtension } from "../../../providers/extension-provider"
import { ActiveOrderClaimSection } from "./components/active-order-claim-section" import { ActiveOrderClaimSection } from "./components/active-order-claim-section"
import { ActiveOrderExchangeSection } from "./components/active-order-exchange-section" import { ActiveOrderExchangeSection } from "./components/active-order-exchange-section"
@@ -22,6 +23,7 @@ export const OrderDetail = () => {
const { id } = useParams() const { id } = useParams()
const { getWidgets } = useExtension() const { getWidgets } = useExtension()
const { plugins = [] } = usePlugins()
const { order, isLoading, isError, error } = useOrder( const { order, isLoading, isError, error } = useOrder(
id!, id!,
@@ -81,8 +83,8 @@ export const OrderDetail = () => {
<ActiveOrderExchangeSection orderPreview={orderPreview!} /> <ActiveOrderExchangeSection orderPreview={orderPreview!} />
<ActiveOrderReturnSection orderPreview={orderPreview!} /> <ActiveOrderReturnSection orderPreview={orderPreview!} />
<OrderGeneralSection order={order} /> <OrderGeneralSection order={order} />
<OrderSummarySection order={order} /> <OrderSummarySection order={order} plugins={plugins} />
<OrderPaymentSection order={order} /> <OrderPaymentSection order={order} plugins={plugins} />
<OrderFulfillmentSection order={order} /> <OrderFulfillmentSection order={order} />
</TwoColumnPage.Main> </TwoColumnPage.Main>
<TwoColumnPage.Sidebar> <TwoColumnPage.Sidebar>
@@ -884,6 +884,10 @@ export interface BaseOrder {
* The tax total applied on the order's shipping methods, excluding promotions. * The tax total applied on the order's shipping methods, excluding promotions.
*/ */
original_shipping_tax_total: number original_shipping_tax_total: number
/**
* The total of the order's credit lines.
*/
credit_line_total: number
} }
export interface BaseOrderFilters export interface BaseOrderFilters
+12
View File
@@ -1227,6 +1227,11 @@ export interface OrderDTO {
*/ */
discount_tax_total: BigNumberValue discount_tax_total: BigNumberValue
/**
* The credit line total of the order.
*/
credit_line_total: BigNumberValue
/** /**
* The gift card total of the order. * The gift card total of the order.
*/ */
@@ -1365,6 +1370,13 @@ export interface OrderDTO {
*/ */
raw_discount_tax_total: BigNumberRawValue raw_discount_tax_total: BigNumberRawValue
/**
* The raw credit line total of the order.
*
* @ignore
*/
raw_credit_line_total: BigNumberRawValue
/** /**
* The raw gift card total of the order. * The raw gift card total of the order.
* *