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ć
2025-04-02 13:57:44 +02:00
committed by GitHub
parent 2270f29ec5
commit a8513019db
9 changed files with 384 additions and 32 deletions

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

View File

@@ -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,
}),
])
)
})
})
},
})

View File

@@ -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)

View File

@@ -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 }) =>

View File

@@ -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))
}
)

View File

@@ -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)
}
)

View File

@@ -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)
}
)

View File

@@ -9,6 +9,7 @@ export type PaymentCollectionStatus =
| "awaiting"
| "authorized"
| "partially_authorized"
| "partially_captured"
| "canceled"
| "failed"
| "completed"

View File

@@ -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.
*/