From a8513019db08d1345e79a15aea7f11389b4918d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:57:44 +0200 Subject: [PATCH] fix(core-flows,dashboard): handling authorized payment collection on OE (#11958) * wip: handling authorized payment collection on OE * fix: update condition * fix: condition for creation * chore: remove commented code * chore: changeset, refactor * chore: typo, comments * fix: add a test case * fix: reorg workflows, partially captured * fix: status enum type --- .changeset/tiny-mice-move.md | 6 + .../__tests__/order-edits/order-edits.spec.ts | 132 ++++++++++++++++++ packages/admin/dashboard/src/lib/payment.ts | 14 +- ...eate-or-update-order-payment-collection.ts | 63 +++++++-- .../order-edit/request-order-edit.ts | 23 ++- .../steps/cancel-payment.ts | 56 ++++++++ .../workflows/cancel-payment-collection.ts | 117 ++++++++++++++++ packages/core/types/src/payment/common.ts | 1 + .../utils/src/payment/payment-collection.ts | 4 + 9 files changed, 384 insertions(+), 32 deletions(-) create mode 100644 .changeset/tiny-mice-move.md create mode 100644 packages/core/core-flows/src/payment-collection/steps/cancel-payment.ts create mode 100644 packages/core/core-flows/src/payment-collection/workflows/cancel-payment-collection.ts diff --git a/.changeset/tiny-mice-move.md b/.changeset/tiny-mice-move.md new file mode 100644 index 0000000000..14408905ec --- /dev/null +++ b/.changeset/tiny-mice-move.md @@ -0,0 +1,6 @@ +--- +"@medusajs/dashboard": patch +"@medusajs/core-flows": patch +--- + +fix(core-flows,dashboard): handle PaymentCollection managemenet on OrderEdit when there is authorized amount diff --git a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts index df0effb259..30e5331ff1 100644 --- a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts +++ b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts @@ -8,7 +8,10 @@ import { import { adminHeaders, createAdminUser, + generatePublishableKey, + generateStoreHeaders, } from "../../../helpers/create-admin-user" +import { medusaTshirtProduct } from "../../__fixtures__/product" jest.setTimeout(30000) @@ -578,5 +581,134 @@ medusaIntegrationTestRunner({ ) }) }) + + describe("Order Edit Payment Collection", () => { + let appContainer + let storeHeaders + let region, product, salesChannel + + const shippingAddressData = { + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + country_code: "US", + province: "CA", + postal_code: "94016", + } + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + const publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) + + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + + product = ( + await api.post( + "/admin/products", + { ...medusaTshirtProduct }, + adminHeaders + ) + ).data.product + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + }) + + it("should add a create a new payment collection if the order has authorized payment collection", async () => { + const cart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + shipping_address: shippingAddressData, + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + storeHeaders + ) + ).data.cart + + const paymentCollection = ( + await api.post( + `/store/payment-collections`, + { cart_id: cart.id }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" }, + storeHeaders + ) + + const order = ( + await api.post( + `/store/carts/${cart.id}/complete`, + { cart_id: cart.id }, + storeHeaders + ) + ).data.order + + await api.post( + `/admin/order-edits`, + { order_id: order.id, description: "Test" }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/items`, + { + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/confirm`, + {}, + adminHeaders + ) + + const orderResult = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + expect(orderResult.payment_collections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: paymentCollection.id, + status: "canceled", + }), + expect.objectContaining({ + id: expect.any(String), + status: "not_paid", + amount: orderResult.total, + }), + ]) + ) + }) + }) }, }) diff --git a/packages/admin/dashboard/src/lib/payment.ts b/packages/admin/dashboard/src/lib/payment.ts index 646892f30e..6ddbb99128 100644 --- a/packages/admin/dashboard/src/lib/payment.ts +++ b/packages/admin/dashboard/src/lib/payment.ts @@ -12,10 +12,12 @@ export const getTotalCaptured = ( }, 0) export const getTotalPending = (paymentCollections: AdminPaymentCollection[]) => - paymentCollections.reduce((acc, paymentCollection) => { - acc += - (paymentCollection.amount as number) - - (paymentCollection.captured_amount as number) + paymentCollections + .filter((pc) => pc.status !== "canceled") + .reduce((acc, paymentCollection) => { + acc += + (paymentCollection.amount as number) - + (paymentCollection.captured_amount as number) - return acc - }, 0) + return acc + }, 0) diff --git a/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts b/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts index a81f642dda..41e4ffe4f6 100644 --- a/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts +++ b/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts @@ -14,6 +14,7 @@ import { import { useRemoteQueryStep } from "../../common" import { updatePaymentCollectionStep } from "../../payment-collection" import { createOrderPaymentCollectionWorkflow } from "./create-order-payment-collection" +import { cancelPaymentCollectionWorkflow } from "../../payment-collection/workflows/cancel-payment-collection" /** * The details of the order payment collection to create or update. @@ -37,10 +38,10 @@ export const createOrUpdateOrderPaymentCollectionWorkflowId = /** * This workflow creates or updates payment collection for an order. It's used by other order-related workflows, * such as {@link createOrderPaymentCollectionWorkflow} to update an order's payment collections based on changes made to the order. - * + * * You can use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around * creating or updating payment collections for an order. - * + * * @example * const { result } = await createOrUpdateOrderPaymentCollectionWorkflow(container) * .run({ @@ -49,9 +50,9 @@ export const createOrUpdateOrderPaymentCollectionWorkflowId = * amount: 20 * } * }) - * + * * @summary - * + * * Create or update payment collection for an order. */ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( @@ -88,12 +89,28 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( variables: { filters: { id: orderPaymentCollectionIds, - status: [PaymentCollectionStatus.NOT_PAID], + status: [ + // To update the collection amoun + PaymentCollectionStatus.NOT_PAID, + PaymentCollectionStatus.AWAITING, + // To cancel the authorized payments and create a new collection + PaymentCollectionStatus.AUTHORIZED, + PaymentCollectionStatus.PARTIALLY_AUTHORIZED, + ], }, }, list: false, }).config({ name: "payment-collection-query" }) + const shouldRecreate = transform( + { existingPaymentCollection }, + ({ existingPaymentCollection }) => + existingPaymentCollection?.status === + PaymentCollectionStatus.AUTHORIZED || + existingPaymentCollection?.status === + PaymentCollectionStatus.PARTIALLY_AUTHORIZED + ) + const amountPending = transform({ order, input }, ({ order, input }) => { const amountToCharge = input.amount ?? 0 const amountPending = @@ -110,9 +127,13 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( }) const updatedPaymentCollections = when( - { existingPaymentCollection, amountPending }, - ({ existingPaymentCollection, amountPending }) => { - return !!existingPaymentCollection?.id && MathBN.gt(amountPending, 0) + { existingPaymentCollection, amountPending, shouldRecreate }, + ({ existingPaymentCollection, amountPending, shouldRecreate }) => { + return ( + !!existingPaymentCollection?.id && + !shouldRecreate && + MathBN.gt(amountPending, 0) + ) } ).then(() => { return updatePaymentCollectionStep({ @@ -124,9 +145,12 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( }) const createdPaymentCollection = when( - { existingPaymentCollection, amountPending }, - ({ existingPaymentCollection, amountPending }) => { - return !!!existingPaymentCollection?.id && MathBN.gt(amountPending, 0) + { existingPaymentCollection, amountPending, shouldRecreate }, + ({ existingPaymentCollection, amountPending, shouldRecreate }) => { + return ( + (!existingPaymentCollection?.id || shouldRecreate) && + MathBN.gt(amountPending, 0) + ) } ).then(() => { return createOrderPaymentCollectionWorkflow.runAsStep({ @@ -137,6 +161,23 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( }) as PaymentCollectionDTO[] }) + when( + { existingPaymentCollection, amountPending, shouldRecreate }, + ({ existingPaymentCollection, amountPending, shouldRecreate }) => { + return ( + !!existingPaymentCollection?.id && + shouldRecreate && + MathBN.gt(amountPending, 0) + ) + } + ).then(() => { + cancelPaymentCollectionWorkflow.runAsStep({ + input: { + payment_collection_id: existingPaymentCollection.id, + }, + }) + }) + const paymentCollections = transform( { updatedPaymentCollections, createdPaymentCollection }, ({ updatedPaymentCollections, createdPaymentCollection }) => diff --git a/packages/core/core-flows/src/order/workflows/order-edit/request-order-edit.ts b/packages/core/core-flows/src/order/workflows/order-edit/request-order-edit.ts index d289033ef5..0d52e63bbb 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/request-order-edit.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/request-order-edit.ts @@ -17,7 +17,6 @@ import { throwIfIsCancelled, throwIfOrderChangeIsNotActive, } from "../../utils/order-validation" -import { createOrUpdateOrderPaymentCollectionWorkflow } from "../create-or-update-order-payment-collection" function getOrderChangesData({ input, @@ -55,14 +54,14 @@ export type RequestOrderEditRequestValidationStepInput = { /** * This step validates that a order edit can be requested. * If the order is canceled or the order change is not active, the step will throw an error. - * + * * :::note - * + * * You can retrieve an order and order change details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), * or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep). - * + * * ::: - * + * * @example * const data = requestOrderEditRequestValidationStep({ * order: { @@ -104,10 +103,10 @@ export const requestOrderEditRequestWorkflowId = "order-edit-request" /** * This workflow requests a previously created order edit request by {@link beginOrderEditOrderWorkflow}. This workflow is used by * the [Request Order Edit Admin API Route](https://docs.medusajs.com/api/admin#order-edits_postordereditsidrequest). - * + * * You can use this workflow within your customizations or your own custom workflows, allowing you to request an order edit * in your custom flows. - * + * * @example * const { result } = await requestOrderEditRequestWorkflow(container) * .run({ @@ -115,9 +114,9 @@ export const requestOrderEditRequestWorkflowId = "order-edit-request" * order_id: "order_123", * } * }) - * + * * @summary - * + * * Request an order edit. */ export const requestOrderEditRequestWorkflow = createWorkflow( @@ -153,12 +152,6 @@ export const requestOrderEditRequestWorkflow = createWorkflow( const updateOrderChangesData = getOrderChangesData({ input, orderChange }) updateOrderChangesStep(updateOrderChangesData) - createOrUpdateOrderPaymentCollectionWorkflow.runAsStep({ - input: { - order_id: order.id, - }, - }) - return new WorkflowResponse(previewOrderChangeStep(order.id)) } ) diff --git a/packages/core/core-flows/src/payment-collection/steps/cancel-payment.ts b/packages/core/core-flows/src/payment-collection/steps/cancel-payment.ts new file mode 100644 index 0000000000..adbca23a20 --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/steps/cancel-payment.ts @@ -0,0 +1,56 @@ +import { IPaymentModuleService, Logger } from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + Modules, + promiseAll, +} from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +/** + * The data to cancel payments. + */ +export interface CancelPaymentStepInput { + /** + * The IDs of the payments to cancel. + */ + ids: string[] +} + +export const cancelPaymentStepId = "cancel-payment" +/** + * This step cancels one or more authorized payments. + */ +export const cancelPaymentStep = createStep( + cancelPaymentStepId, + async (input: CancelPaymentStepInput, { container }) => { + const { ids = [] } = input + const deleted: string[] = [] + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + const service = container.resolve(Modules.PAYMENT) + + if (!ids?.length) { + return new StepResponse([], null) + } + + const promises: Promise[] = [] + + for (const id of ids) { + const promise = service + .cancelPayment(id) + .then((res) => { + deleted.push(id) + }) + .catch((e) => { + logger.error( + `Encountered an error when trying to cancel a payment - ${id} - ${e}` + ) + }) + + promises.push(promise) + } + + await promiseAll(promises) + + return new StepResponse(deleted) + } +) diff --git a/packages/core/core-flows/src/payment-collection/workflows/cancel-payment-collection.ts b/packages/core/core-flows/src/payment-collection/workflows/cancel-payment-collection.ts new file mode 100644 index 0000000000..e6fe7c38ec --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/workflows/cancel-payment-collection.ts @@ -0,0 +1,117 @@ +import { PaymentCollectionDTO } from "@medusajs/framework/types" +import { MedusaError, PaymentCollectionStatus } from "@medusajs/framework/utils" +import { + WorkflowData, + WorkflowResponse, + createStep, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "../../common" +import { updatePaymentCollectionStep } from "../steps/update-payment-collection" +import { cancelPaymentStep } from "../steps/cancel-payment" + +const validatePaymentCollectionCancellationStep = createStep( + "validate-payment-collection-cancellation", + async (input: { paymentCollection: PaymentCollectionDTO }) => { + const { paymentCollection } = input + + if (paymentCollection.status === PaymentCollectionStatus.COMPLETED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot cancel a completed payment collection" + ) + } + + if (paymentCollection.status == PaymentCollectionStatus.CANCELED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Payment collection is already canceled" + ) + } + } +) + +/** + * The data to cancel a payment collection. + */ +export interface CancelPaymentCollectionWorkflowInput { + /** + * The id of the payment collection to cancel. + */ + payment_collection_id: string +} + +export const cancelPaymentCollectionWorkflowId = "cancel-payment-collection" +/** + * This workflow cancels a payment collection that is either not paid or authorized. + * + * Payment colelction that is completed or already canceled cannot be canceled. + * + * @example + * const data = cancelPaymentCollectionStep({ + * payment_collection_id: "paycol_123", + * }) + */ +export const cancelPaymentCollectionWorkflow = createWorkflow( + cancelPaymentCollectionWorkflowId, + ( + input: WorkflowData + ): WorkflowResponse => { + const paymentCollectionQuery = useQueryGraphStep({ + entity: "payment_collection", + fields: [ + "id", + "status", + "payments.id", + "payments.captured_at", + "captured_amount", + ], + filters: { id: input.payment_collection_id }, + }).config({ name: "get-payment-collection" }) + + const paymentCollection = transform( + { paymentCollectionQuery }, + ({ paymentCollectionQuery }) => paymentCollectionQuery.data[0] + ) + + validatePaymentCollectionCancellationStep({ + paymentCollection, + }) + + /** + * Only cancel authorized payments, not captured payments. + */ + const authorizedPaymentIds = transform( + { paymentCollection }, + ({ paymentCollection }) => + paymentCollection.payments + ?.filter((p) => !p.captured_at) + .map((p) => p.id) ?? [] + ) + + const status = transform({ paymentCollection }, ({ paymentCollection }) => + paymentCollection.captured_amount > 0 + ? PaymentCollectionStatus.PARTIALLY_CAPTURED + : PaymentCollectionStatus.CANCELED + ) + + const updatedPaymentCollections = updatePaymentCollectionStep({ + selector: { id: paymentCollection.id }, + update: { + status: status, + }, + }) + + cancelPaymentStep({ + ids: authorizedPaymentIds, + }) + + const resultPaymentCollection = transform( + { updatedPaymentCollections }, + ({ updatedPaymentCollections }) => updatedPaymentCollections[0] + ) + + return new WorkflowResponse(resultPaymentCollection) + } +) diff --git a/packages/core/types/src/payment/common.ts b/packages/core/types/src/payment/common.ts index e65597b380..e75f85ebb3 100644 --- a/packages/core/types/src/payment/common.ts +++ b/packages/core/types/src/payment/common.ts @@ -9,6 +9,7 @@ export type PaymentCollectionStatus = | "awaiting" | "authorized" | "partially_authorized" + | "partially_captured" | "canceled" | "failed" | "completed" diff --git a/packages/core/utils/src/payment/payment-collection.ts b/packages/core/utils/src/payment/payment-collection.ts index ef89a03145..46c8e4f0ed 100644 --- a/packages/core/utils/src/payment/payment-collection.ts +++ b/packages/core/utils/src/payment/payment-collection.ts @@ -28,6 +28,10 @@ export enum PaymentCollectionStatus { * The payment collection is failed. */ FAILED = "failed", + /** + * The payment collection is partially captured. + */ + PARTIALLY_CAPTURED = "partially_captured", /** * The payment collection is completed. */