feat(dashboard,core-flows,js-sdk,types): ability to mark payment as paid (#8679)

* feat(core-flows): create or update payment collections in RMA flows

* chore: change ui to pick payment link from unpaid payment collection

* Apply suggestions from code review

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>

* chore: fix mathbn

* feat(dashboard,core-flows,js-sdk,types): ability to mark payment as paid

* chore: add captured bt

---------

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2024-08-20 22:58:28 +02:00
committed by GitHub
parent 99eca64c20
commit 8bd284779e
12 changed files with 273 additions and 6 deletions

View File

@@ -735,7 +735,8 @@ medusaIntegrationTestRunner({
)
})
it("should create a payment collection successfully", async () => {
it("should create a payment collection successfully & mark as paid", async () => {
const paymentDelta = 171.5
const orderForPayment = (
await api.get(`/admin/orders/${order.id}`, adminHeaders)
).data.order
@@ -746,12 +747,12 @@ medusaIntegrationTestRunner({
expect(paymentCollections[0]).toEqual(
expect.objectContaining({
status: "not_paid",
amount: 171.5,
amount: paymentDelta,
currency_code: "usd",
})
)
const paymentCollection = (
const createdPaymentCollection = (
await api.post(
`/admin/payment-collections`,
{ order_id: order.id, amount: 100 },
@@ -759,7 +760,7 @@ medusaIntegrationTestRunner({
)
).data.payment_collection
expect(paymentCollection).toEqual(
expect(createdPaymentCollection).toEqual(
expect.objectContaining({
currency_code: "usd",
amount: 100,
@@ -769,16 +770,35 @@ medusaIntegrationTestRunner({
const deleted = (
await api.delete(
`/admin/payment-collections/${paymentCollections[0].id}`,
`/admin/payment-collections/${createdPaymentCollection.id}`,
adminHeaders
)
).data
expect(deleted).toEqual({
id: expect.any(String),
id: createdPaymentCollection.id,
object: "payment-collection",
deleted: true,
})
const finalPaymentCollection = (
await api.post(
`/admin/payment-collections/${paymentCollections[0].id}/mark-as-paid`,
{ order_id: order.id },
adminHeaders
)
).data.payment_collection
expect(finalPaymentCollection).toEqual(
expect.objectContaining({
currency_code: "usd",
amount: paymentDelta,
status: "authorized",
authorized_amount: paymentDelta,
captured_amount: paymentDelta,
refunded_amount: 0,
})
)
})
})

View File

@@ -35,6 +35,32 @@ export const useCreatePaymentCollection = (
})
}
export const useMarkPaymentCollectionAsPaid = (
paymentCollectionId: string,
options?: UseMutationOptions<
HttpTypes.AdminPaymentCollectionResponse,
Error,
HttpTypes.AdminMarkPaymentCollectionAsPaid
>
) => {
return useMutation({
mutationFn: (payload) =>
sdk.admin.paymentCollection.markAsPaid(paymentCollectionId, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.all,
})
queryClient.invalidateQueries({
queryKey: paymentCollectionQueryKeys.all,
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeletePaymentCollection = (
options?: Omit<
UseMutationOptions<

View File

@@ -814,6 +814,7 @@
"totalPaidByCustomer": "Total paid by customer",
"capture": "Capture payment",
"refund": "Refund",
"markAsPaid": "Mark as paid",
"statusLabel": "Payment status",
"statusTitle": "Payment Status",
"status": {
@@ -830,6 +831,8 @@
},
"capturePayment": "Payment of {{amount}} will be captured.",
"capturePaymentSuccess": "Payment of {{amount}} successfully captured",
"markAsPaidPayment": "Payment of {{amount}} will be marked as paid.",
"markAsPaidPaymentSuccess": "Payment of {{amount}} successfully marked as paid",
"createRefund": "Create Refund",
"refundPaymentSuccess": "Refund of amount {{amount}} successful",
"createRefundWrongQuantity": "Quantity should be a number between 1 and {{number}}",

View File

@@ -27,18 +27,23 @@ import {
Heading,
StatusBadge,
Text,
toast,
Tooltip,
usePrompt,
} from "@medusajs/ui"
import { AdminPaymentCollection } from "../../../../../../../../core/types/dist/http/payment/admin/entities"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { ButtonMenu } from "../../../../../components/common/button-menu/button-menu.tsx"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { useClaims } from "../../../../../hooks/api/claims.tsx"
import { useExchanges } from "../../../../../hooks/api/exchanges.tsx"
import { useOrderPreview } from "../../../../../hooks/api/orders.tsx"
import { useMarkPaymentCollectionAsPaid } from "../../../../../hooks/api/payment-collections.tsx"
import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date"
import { formatCurrency } from "../../../../../lib/format-currency.ts"
import {
getLocaleAmount,
getStylizedAmount,
@@ -54,6 +59,7 @@ type OrderSummarySectionProps = {
export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const prompt = usePrompt()
const { reservations } = useReservationItems(
{
@@ -104,10 +110,54 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
(pc) => pc.status === "not_paid"
)
const { mutateAsync: markAsPaid } = useMarkPaymentCollectionAsPaid(
unpaidPaymentCollection?.id!
)
const showPayment =
unpaidPaymentCollection && (order?.summary?.pending_difference || 0) > 0
const showRefund = (order?.summary?.pending_difference || 0) < 0
const handleMarkAsPaid = async (
paymentCollection: AdminPaymentCollection
) => {
const res = await prompt({
title: t("orders.payment.markAsPaid"),
description: t("orders.payment.markAsPaidPayment", {
amount: formatCurrency(
paymentCollection.amount as number,
order.currency_code
),
}),
confirmText: t("actions.confirm"),
cancelText: t("actions.cancel"),
variant: "confirmation",
})
if (!res) {
return
}
await markAsPaid(
{ order_id: order.id },
{
onSuccess: () => {
toast.success(
t("orders.payment.markAsPaidPaymentSuccess", {
amount: formatCurrency(
paymentCollection.amount as number,
order.currency_code
),
})
)
},
onError: (error) => {
toast.error(error.message)
},
}
)
}
return (
<Container className="divide-y divide-dashed p-0">
<Header order={order} orderPreview={orderPreview} />
@@ -152,6 +202,16 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
order={order}
/>
)}
{showPayment && (
<Button
size="small"
variant="secondary"
onClick={() => handleMarkAsPaid(unpaidPaymentCollection)}
>
{t("orders.payment.markAsPaid")}
</Button>
)}
</div>
)}
</Container>

View File

@@ -41,6 +41,7 @@ export * from "./exchange/update-exchange-add-item"
export * from "./exchange/update-exchange-shipping-method"
export * from "./get-order-detail"
export * from "./get-orders-list"
export * from "./mark-payment-collection-as-paid"
export * from "./order-edit/begin-order-edit"
export * from "./order-edit/cancel-begin-order-edit"
export * from "./order-edit/confirm-order-edit-request"

View File

@@ -0,0 +1,79 @@
import { PaymentCollectionDTO } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import {
WorkflowData,
WorkflowResponse,
createStep,
createWorkflow,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import { createPaymentSessionsWorkflow } from "../../definition"
import {
authorizePaymentSessionStep,
capturePaymentWorkflow,
} from "../../payment"
/**
* This step validates that the payment collection is not_paid
*/
export const throwUnlessPaymentCollectionNotPaid = createStep(
"validate-existing-payment-collection",
({ paymentCollection }: { paymentCollection: PaymentCollectionDTO }) => {
if (paymentCollection.status !== "not_paid") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Can only mark 'not_paid' payment collection as paid`
)
}
}
)
const systemPaymentProviderId = "pp_system_default"
export const markPaymentCollectionAsPaidId = "mark-payment-collection-as-paid"
/**
* This workflow marks a payment collection for an order as paid.
*/
export const markPaymentCollectionAsPaid = createWorkflow(
markPaymentCollectionAsPaidId,
(
input: WorkflowData<{
payment_collection_id: string
order_id: string
captured_by?: string
}>
) => {
const paymentCollection = useRemoteQueryStep({
entry_point: "payment_collection",
fields: ["id", "status", "amount"],
variables: { id: input.payment_collection_id },
throw_if_key_not_found: true,
list: false,
})
throwUnlessPaymentCollectionNotPaid({ paymentCollection })
const paymentSession = createPaymentSessionsWorkflow.runAsStep({
input: {
payment_collection_id: paymentCollection.id,
provider_id: systemPaymentProviderId,
data: {},
context: {},
},
})
const payment = authorizePaymentSessionStep({
id: paymentSession.id,
context: { order_id: input.order_id },
})
capturePaymentWorkflow.runAsStep({
input: {
payment_id: payment.id,
captured_by: input.captured_by,
amount: paymentCollection.amount,
},
})
return new WorkflowResponse(payment)
}
)

View File

@@ -60,4 +60,21 @@ export class PaymentCollection {
}
)
}
async markAsPaid(
id: string,
body: HttpTypes.AdminMarkPaymentCollectionAsPaid,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminPaymentCollectionResponse>(
`/admin/payment-collections/${id}/mark-as-paid`,
{
method: "POST",
headers,
body,
query,
}
)
}
}

View File

@@ -17,3 +17,7 @@ export interface AdminCreatePaymentCollection {
order_id: string
amount?: number
}
export interface AdminMarkPaymentCollectionAsPaid {
order_id: string
}

View File

@@ -0,0 +1,32 @@
import { markPaymentCollectionAsPaid } from "@medusajs/core-flows"
import { HttpTypes } from "@medusajs/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../types/routing"
import { refetchEntity } from "../../../../utils/refetch-entity"
import { AdminMarkPaymentCollectionPaidType } from "../../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminMarkPaymentCollectionPaidType>,
res: MedusaResponse<HttpTypes.AdminPaymentCollectionResponse>
) => {
const { id } = req.params
await markPaymentCollectionAsPaid(req.scope).run({
input: {
...req.body,
payment_collection_id: id,
captured_by: req.auth_context.actor_id,
},
})
const paymentCollection = await refetchEntity(
"payment_collection",
id,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ payment_collection: paymentCollection })
}

View File

@@ -5,6 +5,7 @@ import * as queryConfig from "./query-config"
import {
AdminCreatePaymentCollection,
AdminGetPaymentCollectionParams,
AdminMarkPaymentCollectionPaid,
} from "./validators"
export const adminPaymentCollectionsMiddlewares: MiddlewareRoute[] = [
@@ -19,6 +20,17 @@ export const adminPaymentCollectionsMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/payment-collections/:id/mark-as-paid",
middlewares: [
validateAndTransformBody(AdminMarkPaymentCollectionPaid),
validateAndTransformQuery(
AdminGetPaymentCollectionParams,
queryConfig.retrievePaymentCollectionTransformQueryConfig
),
],
},
{
method: ["DELETE"],
matcher: "/admin/payment-collections/:id",

View File

@@ -3,7 +3,11 @@ export const defaultPaymentCollectionFields = [
"currency_code",
"amount",
"status",
"authorized_amount",
"captured_amount",
"refunded_amount",
"*payment_sessions",
"*payments",
]
export const retrievePaymentCollectionTransformQueryConfig = {

View File

@@ -15,3 +15,12 @@ export const AdminCreatePaymentCollection = z
amount: z.number(),
})
.strict()
export type AdminMarkPaymentCollectionPaidType = z.infer<
typeof AdminMarkPaymentCollectionPaid
>
export const AdminMarkPaymentCollectionPaid = z
.object({
order_id: z.string(),
})
.strict()