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:
6
.changeset/tiny-mice-move.md
Normal file
6
.changeset/tiny-mice-move.md
Normal 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 {
|
||||
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,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
| "authorized"
|
||||
| "partially_authorized"
|
||||
| "partially_captured"
|
||||
| "canceled"
|
||||
| "failed"
|
||||
| "completed"
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user