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
This commit is contained in:
Frane Polić authored and GitHub committed 2025-04-02 13:57:44 +02:00
1 parent 2270f29ec5
commit a8513019db
9 files changed
+384 -32

No files matched your search

+6
View File
@@ -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
@@ -8,7 +8,10 @@ import {
import { import {
adminHeaders, adminHeaders,
createAdminUser, createAdminUser,
generatePublishableKey,
generateStoreHeaders,
} from "../../../helpers/create-admin-user" } from "../../../helpers/create-admin-user"
import { medusaTshirtProduct } from "../../__fixtures__/product"
jest.setTimeout(30000) 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,
}),
])
)
})
})
}, },
}) })
+8 -6
View File
@@ -12,10 +12,12 @@ export const getTotalCaptured = (
}, 0) }, 0)
export const getTotalPending = (paymentCollections: AdminPaymentCollection[]) => export const getTotalPending = (paymentCollections: AdminPaymentCollection[]) =>
paymentCollections.reduce((acc, paymentCollection) => { paymentCollections
acc += .filter((pc) => pc.status !== "canceled")
(paymentCollection.amount as number) - .reduce((acc, paymentCollection) => {
(paymentCollection.captured_amount as number) acc +=
(paymentCollection.amount as number) -
(paymentCollection.captured_amount as number)
return acc return acc
}, 0) }, 0)
@@ -14,6 +14,7 @@ import {
import { useRemoteQueryStep } from "../../common" import { useRemoteQueryStep } from "../../common"
import { updatePaymentCollectionStep } from "../../payment-collection" import { updatePaymentCollectionStep } from "../../payment-collection"
import { createOrderPaymentCollectionWorkflow } from "./create-order-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. * 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, * 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. * 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 * 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. * creating or updating payment collections for an order.
* *
* @example * @example
* const { result } = await createOrUpdateOrderPaymentCollectionWorkflow(container) * const { result } = await createOrUpdateOrderPaymentCollectionWorkflow(container)
* .run({ * .run({
@@ -49,9 +50,9 @@ export const createOrUpdateOrderPaymentCollectionWorkflowId =
* amount: 20 * amount: 20
* } * }
* }) * })
* *
* @summary * @summary
* *
* Create or update payment collection for an order. * Create or update payment collection for an order.
*/ */
export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow(
@@ -88,12 +89,28 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow(
variables: { variables: {
filters: { filters: {
id: orderPaymentCollectionIds, 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, list: false,
}).config({ name: "payment-collection-query" }) }).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 amountPending = transform({ order, input }, ({ order, input }) => {
const amountToCharge = input.amount ?? 0 const amountToCharge = input.amount ?? 0
const amountPending = const amountPending =
@@ -110,9 +127,13 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow(
}) })
const updatedPaymentCollections = when( const updatedPaymentCollections = when(
{ existingPaymentCollection, amountPending }, { existingPaymentCollection, amountPending, shouldRecreate },
({ existingPaymentCollection, amountPending }) => { ({ existingPaymentCollection, amountPending, shouldRecreate }) => {
return !!existingPaymentCollection?.id && MathBN.gt(amountPending, 0) return (
!!existingPaymentCollection?.id &&
!shouldRecreate &&
MathBN.gt(amountPending, 0)
)
} }
).then(() => { ).then(() => {
return updatePaymentCollectionStep({ return updatePaymentCollectionStep({
@@ -124,9 +145,12 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow(
}) })
const createdPaymentCollection = when( const createdPaymentCollection = when(
{ existingPaymentCollection, amountPending }, { existingPaymentCollection, amountPending, shouldRecreate },
({ existingPaymentCollection, amountPending }) => { ({ existingPaymentCollection, amountPending, shouldRecreate }) => {
return !!!existingPaymentCollection?.id && MathBN.gt(amountPending, 0) return (
(!existingPaymentCollection?.id || shouldRecreate) &&
MathBN.gt(amountPending, 0)
)
} }
).then(() => { ).then(() => {
return createOrderPaymentCollectionWorkflow.runAsStep({ return createOrderPaymentCollectionWorkflow.runAsStep({
@@ -137,6 +161,23 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow(
}) as PaymentCollectionDTO[] }) 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( const paymentCollections = transform(
{ updatedPaymentCollections, createdPaymentCollection }, { updatedPaymentCollections, createdPaymentCollection },
({ updatedPaymentCollections, createdPaymentCollection }) => ({ updatedPaymentCollections, createdPaymentCollection }) =>
@@ -17,7 +17,6 @@ import {
throwIfIsCancelled, throwIfIsCancelled,
throwIfOrderChangeIsNotActive, throwIfOrderChangeIsNotActive,
} from "../../utils/order-validation" } from "../../utils/order-validation"
import { createOrUpdateOrderPaymentCollectionWorkflow } from "../create-or-update-order-payment-collection"
function getOrderChangesData({ function getOrderChangesData({
input, input,
@@ -55,14 +54,14 @@ export type RequestOrderEditRequestValidationStepInput = {
/** /**
* This step validates that a order edit can be requested. * 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. * If the order is canceled or the order change is not active, the step will throw an error.
* *
* :::note * :::note
* *
* You can retrieve an order and order change details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), * 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). * or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep).
* *
* ::: * :::
* *
* @example * @example
* const data = requestOrderEditRequestValidationStep({ * const data = requestOrderEditRequestValidationStep({
* order: { * 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 * 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). * 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 * You can use this workflow within your customizations or your own custom workflows, allowing you to request an order edit
* in your custom flows. * in your custom flows.
* *
* @example * @example
* const { result } = await requestOrderEditRequestWorkflow(container) * const { result } = await requestOrderEditRequestWorkflow(container)
* .run({ * .run({
@@ -115,9 +114,9 @@ export const requestOrderEditRequestWorkflowId = "order-edit-request"
* order_id: "order_123", * order_id: "order_123",
* } * }
* }) * })
* *
* @summary * @summary
* *
* Request an order edit. * Request an order edit.
*/ */
export const requestOrderEditRequestWorkflow = createWorkflow( export const requestOrderEditRequestWorkflow = createWorkflow(
@@ -153,12 +152,6 @@ export const requestOrderEditRequestWorkflow = createWorkflow(
const updateOrderChangesData = getOrderChangesData({ input, orderChange }) const updateOrderChangesData = getOrderChangesData({ input, orderChange })
updateOrderChangesStep(updateOrderChangesData) updateOrderChangesStep(updateOrderChangesData)
createOrUpdateOrderPaymentCollectionWorkflow.runAsStep({
input: {
order_id: order.id,
},
})
return new WorkflowResponse(previewOrderChangeStep(order.id)) return new WorkflowResponse(previewOrderChangeStep(order.id))
} }
) )
@@ -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<Logger>(ContainerRegistrationKeys.LOGGER)
const service = container.resolve<IPaymentModuleService>(Modules.PAYMENT)
if (!ids?.length) {
return new StepResponse([], null)
}
const promises: Promise<void>[] = []
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)
}
)
@@ -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<CancelPaymentCollectionWorkflowInput>
): WorkflowResponse<PaymentCollectionDTO> => {
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)
}
)
@@ -9,6 +9,7 @@ export type PaymentCollectionStatus =
| "awaiting" | "awaiting"
| "authorized" | "authorized"
| "partially_authorized" | "partially_authorized"
| "partially_captured"
| "canceled" | "canceled"
| "failed" | "failed"
| "completed" | "completed"
@@ -28,6 +28,10 @@ export enum PaymentCollectionStatus {
* The payment collection is failed. * The payment collection is failed.
*/ */
FAILED = "failed", FAILED = "failed",
/**
* The payment collection is partially captured.
*/
PARTIALLY_CAPTURED = "partially_captured",
/** /**
* The payment collection is completed. * The payment collection is completed.
*/ */