diff --git a/integration-tests/http/__tests__/claims/claims.spec.ts b/integration-tests/http/__tests__/claims/claims.spec.ts index 5e7d79f655..7960ff7509 100644 --- a/integration-tests/http/__tests__/claims/claims.spec.ts +++ b/integration-tests/http/__tests__/claims/claims.spec.ts @@ -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, + }) + ) }) }) diff --git a/packages/admin-next/dashboard/src/hooks/api/payment-collections.tsx b/packages/admin-next/dashboard/src/hooks/api/payment-collections.tsx index 8164cda952..557ff4d457 100644 --- a/packages/admin-next/dashboard/src/hooks/api/payment-collections.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/payment-collections.tsx @@ -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< diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 4c890b0318..d3b6133df3 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -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}}", diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx index 31658f7984..c932fc11e9 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx @@ -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 (
@@ -152,6 +202,16 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => { order={order} /> )} + + {showPayment && ( + + )} )} diff --git a/packages/core/core-flows/src/order/workflows/index.ts b/packages/core/core-flows/src/order/workflows/index.ts index 92273db68e..f6e6294c9c 100644 --- a/packages/core/core-flows/src/order/workflows/index.ts +++ b/packages/core/core-flows/src/order/workflows/index.ts @@ -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" diff --git a/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts b/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts new file mode 100644 index 0000000000..d02029183a --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts @@ -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) + } +) diff --git a/packages/core/js-sdk/src/admin/payment-collection.ts b/packages/core/js-sdk/src/admin/payment-collection.ts index ca8dacc0cf..12551002a7 100644 --- a/packages/core/js-sdk/src/admin/payment-collection.ts +++ b/packages/core/js-sdk/src/admin/payment-collection.ts @@ -60,4 +60,21 @@ export class PaymentCollection { } ) } + + async markAsPaid( + id: string, + body: HttpTypes.AdminMarkPaymentCollectionAsPaid, + query?: SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/payment-collections/${id}/mark-as-paid`, + { + method: "POST", + headers, + body, + query, + } + ) + } } diff --git a/packages/core/types/src/http/payment/admin/payloads.ts b/packages/core/types/src/http/payment/admin/payloads.ts index 3396965b1a..b407662567 100644 --- a/packages/core/types/src/http/payment/admin/payloads.ts +++ b/packages/core/types/src/http/payment/admin/payloads.ts @@ -17,3 +17,7 @@ export interface AdminCreatePaymentCollection { order_id: string amount?: number } + +export interface AdminMarkPaymentCollectionAsPaid { + order_id: string +} diff --git a/packages/medusa/src/api/admin/payment-collections/[id]/mark-as-paid/route.ts b/packages/medusa/src/api/admin/payment-collections/[id]/mark-as-paid/route.ts new file mode 100644 index 0000000000..46767f49b7 --- /dev/null +++ b/packages/medusa/src/api/admin/payment-collections/[id]/mark-as-paid/route.ts @@ -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, + res: MedusaResponse +) => { + 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 }) +} diff --git a/packages/medusa/src/api/admin/payment-collections/middlewares.ts b/packages/medusa/src/api/admin/payment-collections/middlewares.ts index 4d8400dad6..9ab5f65199 100644 --- a/packages/medusa/src/api/admin/payment-collections/middlewares.ts +++ b/packages/medusa/src/api/admin/payment-collections/middlewares.ts @@ -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", diff --git a/packages/medusa/src/api/admin/payment-collections/query-config.ts b/packages/medusa/src/api/admin/payment-collections/query-config.ts index 5dc3aa9d48..00372fff77 100644 --- a/packages/medusa/src/api/admin/payment-collections/query-config.ts +++ b/packages/medusa/src/api/admin/payment-collections/query-config.ts @@ -3,7 +3,11 @@ export const defaultPaymentCollectionFields = [ "currency_code", "amount", "status", + "authorized_amount", + "captured_amount", + "refunded_amount", "*payment_sessions", + "*payments", ] export const retrievePaymentCollectionTransformQueryConfig = { diff --git a/packages/medusa/src/api/admin/payment-collections/validators.ts b/packages/medusa/src/api/admin/payment-collections/validators.ts index d9d6c9913f..e3b424f979 100644 --- a/packages/medusa/src/api/admin/payment-collections/validators.ts +++ b/packages/medusa/src/api/admin/payment-collections/validators.ts @@ -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()