diff --git a/integration-tests/http/__tests__/payment-collection/admin/payment-sessions.spec.ts b/integration-tests/http/__tests__/payment-collection/admin/payment-sessions.spec.ts new file mode 100644 index 0000000000..102d261ff3 --- /dev/null +++ b/integration-tests/http/__tests__/payment-collection/admin/payment-sessions.spec.ts @@ -0,0 +1,65 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + beforeAll(() => {}) + + beforeEach(async () => { + const container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + }) + + describe("POST /admin/payment-collections/:id/payment-sessions", () => { + let region + + beforeEach(async () => { + region = ( + await api.post( + "/admin/regions", + { name: "United States", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + }) + + it("should create a payment session", async () => { + const paymentCollection = ( + await api.post(`/store/payment-collections`, { + region_id: region.id, + cart_id: "cart.id", + amount: 150, + currency_code: "usd", + }) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" } + ) + + // Adding a second payment session to ensure only one session gets created + const { + data: { payment_collection }, + } = await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" } + ) + + expect(payment_collection.payment_sessions).toEqual([ + expect.objectContaining({ + currency_code: "usd", + provider_id: "pp_system_default", + status: "pending", + amount: 150, + }), + ]) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts b/integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts index 36823c6a51..07953c7bae 100644 --- a/integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts +++ b/integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts @@ -27,18 +27,23 @@ medusaIntegrationTestRunner({ }) describe("createPaymentSessionWorkflow", () => { - it("should create payment sessions", async () => { - const region = await regionModule.create({ + let region + let paymentCollection + + beforeEach(async () => { + region = await regionModule.create({ currency_code: "usd", name: "US", }) - let paymentCollection = await paymentModule.createPaymentCollections({ + paymentCollection = await paymentModule.createPaymentCollections({ currency_code: "usd", amount: 1000, region_id: region.id, }) + }) + it("should create payment sessions", async () => { await createPaymentSessionsWorkflow(appContainer).run({ input: { payment_collection_id: paymentCollection.id, @@ -72,6 +77,47 @@ medusaIntegrationTestRunner({ ) }) + it("should delete existing sessions when create payment sessions", async () => { + await createPaymentSessionsWorkflow(appContainer).run({ + input: { + payment_collection_id: paymentCollection.id, + provider_id: "pp_system_default", + context: {}, + data: {}, + }, + }) + + await createPaymentSessionsWorkflow(appContainer).run({ + input: { + payment_collection_id: paymentCollection.id, + provider_id: "pp_system_default", + context: {}, + data: {}, + }, + }) + + paymentCollection = await paymentModule.retrievePaymentCollection( + paymentCollection.id, + { relations: ["payment_sessions"] } + ) + + expect(paymentCollection).toEqual( + expect.objectContaining({ + id: paymentCollection.id, + currency_code: "usd", + amount: 1000, + region_id: region.id, + payment_sessions: [ + expect.objectContaining({ + amount: 1000, + currency_code: "usd", + provider_id: "pp_system_default", + }), + ], + }) + ) + }) + describe("compensation", () => { it("should delete created payment collection if a subsequent step fails", async () => { const workflow = createPaymentSessionsWorkflow(appContainer) diff --git a/packages/core/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts b/packages/core/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts index 809b44b0c7..cd5b9b3223 100644 --- a/packages/core/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts +++ b/packages/core/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts @@ -7,10 +7,8 @@ import { transform, } from "@medusajs/workflows-sdk" import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" -import { - deletePaymentSessionStep, - updatePaymentCollectionStep, -} from "../../payment-collection" +import { updatePaymentCollectionStep } from "../../../payment-collection" +import { deletePaymentSessionsWorkflow } from "../../../payment-collection/workflows/delete-payment-sessions" type WorklowInput = { cart_id: string @@ -55,10 +53,19 @@ export const refreshPaymentCollectionForCartWorkflow = createWorkflow( }) const cart = transform({ carts }, (data) => data.carts[0]) + const deletePaymentSessionInput = transform( + { paymentCollection: cart.payment_collection }, + (data) => { + return { + ids: + data.paymentCollection?.payment_sessions?.map((ps) => ps.id) || [], + } + } + ) parallelize( - deletePaymentSessionStep({ - payment_session_id: cart.payment_collection.payment_sessions?.[0].id, + deletePaymentSessionsWorkflow.runAsStep({ + input: deletePaymentSessionInput, }), updatePaymentCollectionStep({ selector: { id: cart.payment_collection.id }, diff --git a/packages/core/core-flows/src/definition/index.ts b/packages/core/core-flows/src/definition/index.ts index 58772edfd2..cdbfb6b0df 100644 --- a/packages/core/core-flows/src/definition/index.ts +++ b/packages/core/core-flows/src/definition/index.ts @@ -1,3 +1,3 @@ +export * from "../payment-collection" export * from "./cart" export * from "./line-item" -export * from "./payment-collection" diff --git a/packages/core/core-flows/src/definition/payment-collection/steps/delete-payment-session.ts b/packages/core/core-flows/src/definition/payment-collection/steps/delete-payment-session.ts deleted file mode 100644 index 457590205f..0000000000 --- a/packages/core/core-flows/src/definition/payment-collection/steps/delete-payment-session.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { IPaymentModuleService } from "@medusajs/types" -import { StepResponse, createStep } from "@medusajs/workflows-sdk" - -interface StepInput { - payment_session_id?: string -} - -export const deletePaymentSessionStepId = "delete-payment-session" -export const deletePaymentSessionStep = createStep( - deletePaymentSessionStepId, - async (input: StepInput, { container }) => { - const service = container.resolve( - ModuleRegistrationName.PAYMENT - ) - - if (!input.payment_session_id) { - return new StepResponse(void 0, null) - } - - const [session] = await service.listPaymentSessions({ - id: input.payment_session_id, - }) - - await service.deletePaymentSession(input.payment_session_id) - - return new StepResponse(input.payment_session_id, session) - }, - async (input, { container }) => { - const service = container.resolve( - ModuleRegistrationName.PAYMENT - ) - - if (!input || !input.payment_collection) { - return - } - - await service.createPaymentSession(input.payment_collection.id, { - provider_id: input.provider_id, - currency_code: input.currency_code, - amount: input.amount, - data: input.data ?? {}, - context: input.context, - }) - } -) diff --git a/packages/core/core-flows/src/definition/payment-collection/steps/index.ts b/packages/core/core-flows/src/definition/payment-collection/steps/index.ts deleted file mode 100644 index 98fc3b72d5..0000000000 --- a/packages/core/core-flows/src/definition/payment-collection/steps/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./create-payment-session" -export * from "./delete-payment-session" -export * from "./retrieve-payment-collection" -export * from "./update-payment-collection" - diff --git a/packages/core/core-flows/src/definition/payment-collection/steps/retrieve-payment-collection.ts b/packages/core/core-flows/src/definition/payment-collection/steps/retrieve-payment-collection.ts deleted file mode 100644 index cafbf8fc04..0000000000 --- a/packages/core/core-flows/src/definition/payment-collection/steps/retrieve-payment-collection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { - FindConfig, - IPaymentModuleService, - PaymentCollectionDTO, -} from "@medusajs/types" -import { StepResponse, createStep } from "@medusajs/workflows-sdk" - -interface StepInput { - id: string - config?: FindConfig -} - -export const retrievePaymentCollectionStepId = "retrieve-payment-collection" -export const retrievePaymentCollectionStep = createStep( - retrievePaymentCollectionStepId, - async (data: StepInput, { container }) => { - const paymentModuleService = container.resolve( - ModuleRegistrationName.PAYMENT - ) - - const paymentCollection = - await paymentModuleService.retrievePaymentCollection(data.id, data.config) - - return new StepResponse(paymentCollection) - } -) diff --git a/packages/core/core-flows/src/definition/payment-collection/workflows/create-payment-session.ts b/packages/core/core-flows/src/definition/payment-collection/workflows/create-payment-session.ts deleted file mode 100644 index 5e1bb403ec..0000000000 --- a/packages/core/core-flows/src/definition/payment-collection/workflows/create-payment-session.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { PaymentProviderContext, PaymentSessionDTO } from "@medusajs/types" -import { - WorkflowData, - createWorkflow, - transform, -} from "@medusajs/workflows-sdk" -import { - createPaymentSessionStep, - retrievePaymentCollectionStep, -} from "../steps" - -interface WorkflowInput { - payment_collection_id: string - provider_id: string - data?: Record - context?: PaymentProviderContext -} - -export const createPaymentSessionsWorkflowId = "create-payment-sessions" -export const createPaymentSessionsWorkflow = createWorkflow( - createPaymentSessionsWorkflowId, - (input: WorkflowData): WorkflowData => { - const paymentCollection = retrievePaymentCollectionStep({ - id: input.payment_collection_id, - config: { - select: ["id", "amount", "currency_code"], - }, - }) - - const paymentSessionInput = transform( - { paymentCollection, input }, - (data) => { - return { - payment_collection_id: data.input.payment_collection_id, - provider_id: data.input.provider_id, - data: data.input.data, - context: data.input.context, - amount: data.paymentCollection.amount, - currency_code: data.paymentCollection.currency_code, - } - } - ) - - const created = createPaymentSessionStep(paymentSessionInput) - return created - } -) diff --git a/packages/core/core-flows/src/index.ts b/packages/core/core-flows/src/index.ts index 1eb1dd04ff..4eaf9b70fd 100644 --- a/packages/core/core-flows/src/index.ts +++ b/packages/core/core-flows/src/index.ts @@ -11,6 +11,7 @@ export * from "./inventory" export * from "./invite" export * from "./order" export * from "./payment" +export * from "./payment-collection" export * from "./price-list" export * from "./pricing" export * from "./product" diff --git a/packages/core/core-flows/src/definition/payment-collection/index.ts b/packages/core/core-flows/src/payment-collection/index.ts similarity index 100% rename from packages/core/core-flows/src/definition/payment-collection/index.ts rename to packages/core/core-flows/src/payment-collection/index.ts diff --git a/packages/core/core-flows/src/definition/payment-collection/steps/create-payment-session.ts b/packages/core/core-flows/src/payment-collection/steps/create-payment-session.ts similarity index 100% rename from packages/core/core-flows/src/definition/payment-collection/steps/create-payment-session.ts rename to packages/core/core-flows/src/payment-collection/steps/create-payment-session.ts diff --git a/packages/core/core-flows/src/payment-collection/steps/delete-payment-sessions.ts b/packages/core/core-flows/src/payment-collection/steps/delete-payment-sessions.ts new file mode 100644 index 0000000000..e5bfbef331 --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/steps/delete-payment-sessions.ts @@ -0,0 +1,101 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + IPaymentModuleService, + Logger, + PaymentSessionDTO, +} from "@medusajs/types" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + ids: string[] +} + +// Note: This step should not be used alone as it doesn't consider a revert +// Use deletePaymentSessionsWorkflow instead that uses this step +export const deletePaymentSessionsStepId = "delete-payment-sessions" +export const deletePaymentSessionsStep = createStep( + deletePaymentSessionsStepId, + async (input: StepInput, { container }) => { + const { ids = [] } = input + const deleted: PaymentSessionDTO[] = [] + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + if (!ids?.length) { + return new StepResponse([], null) + } + + for (const id of ids) { + const select = [ + "provider_id", + "currency_code", + "amount", + "data", + "context", + "payment_collection.id", + ] + + const [session] = await service.listPaymentSessions({ id }, { select }) + + // As this requires an external method call, we will try to delete as many successful calls + // as possible and pass them over to the compensation step to be recreated if any of the + // payment sessions fails to delete. + try { + await service.deletePaymentSession(id) + + deleted.push(session) + } catch (e) { + logger.error( + `Encountered an error when trying to delete payment session - ${id} - ${e}` + ) + } + } + + return new StepResponse( + deleted.map((d) => d.id), + deleted + ) + }, + async (deletedPaymentSessions, { container }) => { + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + if (!deletedPaymentSessions?.length) { + return + } + + for (const paymentSession of deletedPaymentSessions) { + if (!paymentSession.payment_collection?.id) { + continue + } + + const payload = { + provider_id: paymentSession.provider_id, + currency_code: paymentSession.currency_code, + amount: paymentSession.amount, + data: paymentSession.data ?? {}, + context: paymentSession.context, + } + + // Creating a payment session also requires an external call. If we fail to recreate the + // payment step, we would have to compensate successfully even though it didn't recreate + // all the necessary sessions. We accept a level of risk here for the payment collection to + // be set in an incomplete state. + try { + await service.createPaymentSession( + paymentSession.payment_collection?.id, + payload + ) + } catch (e) { + logger.error( + `Encountered an error when trying to recreate a payment session - ${payload} - ${e}` + ) + } + } + } +) diff --git a/packages/core/core-flows/src/payment-collection/steps/index.ts b/packages/core/core-flows/src/payment-collection/steps/index.ts new file mode 100644 index 0000000000..08a6061139 --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/steps/index.ts @@ -0,0 +1,4 @@ +export * from "./create-payment-session" +export * from "./delete-payment-sessions" +export * from "./update-payment-collection" +export * from "./validate-deleted-payment-sessions" diff --git a/packages/core/core-flows/src/definition/payment-collection/steps/update-payment-collection.ts b/packages/core/core-flows/src/payment-collection/steps/update-payment-collection.ts similarity index 100% rename from packages/core/core-flows/src/definition/payment-collection/steps/update-payment-collection.ts rename to packages/core/core-flows/src/payment-collection/steps/update-payment-collection.ts diff --git a/packages/core/core-flows/src/payment-collection/steps/validate-deleted-payment-sessions.ts b/packages/core/core-flows/src/payment-collection/steps/validate-deleted-payment-sessions.ts new file mode 100644 index 0000000000..f307a5ef90 --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/steps/validate-deleted-payment-sessions.ts @@ -0,0 +1,25 @@ +import { MedusaError } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + idsToDelete: string[] + idsDeleted: string[] +} + +export const validateDeletedPaymentSessionsStepId = + "validate-deleted-payment-sessions" +export const validateDeletedPaymentSessionsStep = createStep( + validateDeletedPaymentSessionsStepId, + async (input: StepInput) => { + const { idsToDelete = [], idsDeleted = [] } = input + + if (idsToDelete.length !== idsDeleted.length) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Could not delete all payment sessions` + ) + } + + return new StepResponse(void 0) + } +) diff --git a/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts b/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts new file mode 100644 index 0000000000..f2176f40d8 --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts @@ -0,0 +1,67 @@ +import { PaymentProviderContext, PaymentSessionDTO } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + parallelize, + transform, +} from "@medusajs/workflows-sdk" +import { useRemoteQueryStep } from "../../common" +import { createPaymentSessionStep } from "../steps" +import { deletePaymentSessionsWorkflow } from "./delete-payment-sessions" + +interface WorkflowInput { + payment_collection_id: string + provider_id: string + data?: Record + context?: PaymentProviderContext +} + +export const createPaymentSessionsWorkflowId = "create-payment-sessions" +export const createPaymentSessionsWorkflow = createWorkflow( + createPaymentSessionsWorkflowId, + (input: WorkflowData): WorkflowData => { + const paymentCollection = useRemoteQueryStep({ + entry_point: "payment_collection", + fields: ["id", "amount", "currency_code", "payment_sessions.*"], + variables: { id: input.payment_collection_id }, + list: false, + }) + + const paymentSessionInput = transform( + { paymentCollection, input }, + (data) => { + return { + payment_collection_id: data.input.payment_collection_id, + provider_id: data.input.provider_id, + data: data.input.data, + context: data.input.context, + amount: data.paymentCollection.amount, + currency_code: data.paymentCollection.currency_code, + } + } + ) + + const deletePaymentSessionInput = transform( + { paymentCollection }, + (data) => { + return { + ids: + data.paymentCollection?.payment_sessions?.map((ps) => ps.id) || [], + } + } + ) + + // Note: We are deleting an existing active session before creating a new one + // for a payment collection as we don't support split payments at the moment. + // When we are ready to accept split payments, this along with other workflows + // need to be handled correctly + const [created] = parallelize( + createPaymentSessionStep(paymentSessionInput), + deletePaymentSessionsWorkflow.runAsStep({ + input: deletePaymentSessionInput, + }) + ) + + return created + } +) diff --git a/packages/core/core-flows/src/payment-collection/workflows/delete-payment-sessions.ts b/packages/core/core-flows/src/payment-collection/workflows/delete-payment-sessions.ts new file mode 100644 index 0000000000..f87889cc43 --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/workflows/delete-payment-sessions.ts @@ -0,0 +1,24 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { + deletePaymentSessionsStep, + validateDeletedPaymentSessionsStep, +} from "../steps" + +interface WorkflowInput { + ids: string[] +} + +export const deletePaymentSessionsWorkflowId = "delete-payment-sessions" +export const deletePaymentSessionsWorkflow = createWorkflow( + deletePaymentSessionsWorkflowId, + (input: WorkflowData) => { + const idsDeleted = deletePaymentSessionsStep({ ids: input.ids }) + + validateDeletedPaymentSessionsStep({ + idsToDelete: input.ids, + idsDeleted, + }) + + return idsDeleted + } +) diff --git a/packages/core/core-flows/src/definition/payment-collection/workflows/index.ts b/packages/core/core-flows/src/payment-collection/workflows/index.ts similarity index 100% rename from packages/core/core-flows/src/definition/payment-collection/workflows/index.ts rename to packages/core/core-flows/src/payment-collection/workflows/index.ts