From 84208aafc1afdb7a6de4b26f10fdf8305095badd Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 5 Mar 2024 09:40:47 +0100 Subject: [PATCH] feat: Create payment sessions (#6549) ~~Opening a draft PR to discuss a couple of implementation details that we should align on~~ **What** Add workflow and API endpoint for creating payment sessions for a payment collection. Endpoint is currently `POST /store/payment-collection/:id/payment-sessions`. I suggested an alternative in a comment below. Please note, we intentionally do not want to support creating payment sessions in bulk, as this would become a mess when having to manage multiple calls to third-party providers. --- .../payment/payment-session.workflows.spec.ts | 142 ++++++++++++++++++ packages/core-flows/src/definition/index.ts | 2 +- .../definition/payment-collection/index.ts | 2 + .../steps/create-payment-session.ts | 46 ++++++ .../payment-collection/steps/index.ts | 3 + .../steps/retrieve-payment-collection.ts | 27 ++++ .../workflows/create-payment-session.ts | 47 ++++++ .../payment-collection/workflows/index.ts | 1 + .../medusa/src/api-v2/store/carts/route.ts | 15 +- .../[id]/payment-sessions/middlewares.ts | 11 ++ .../[id]/payment-sessions/query-config.ts | 20 +++ .../[id]/payment-sessions/route.ts | 52 +++++++ .../[id]/payment-sessions/validators.ts | 7 + .../payment-stripe/src/core/stripe-base.ts | 48 +++--- .../integration-tests/__fixtures__/data.ts | 10 +- .../services/payment-module/index.spec.ts | 75 +++++---- packages/payment/package.json | 1 + .../src/migrations/Migration20240225134525.ts | 5 +- .../payment/src/models/payment-collection.ts | 4 +- .../payment/src/models/payment-session.ts | 13 +- packages/payment/src/models/payment.ts | 16 +- packages/payment/src/providers/system.ts | 4 +- .../payment/src/services/payment-module.ts | 106 ++++++++----- .../payment/src/services/payment-provider.ts | 22 +-- packages/types/src/payment/common.ts | 13 ++ packages/types/src/payment/mutations.ts | 30 +++- packages/types/src/payment/provider.ts | 44 +++--- packages/types/src/payment/service.ts | 7 + .../src/payment/abstract-payment-provider.ts | 9 +- yarn.lock | 3 +- 30 files changed, 603 insertions(+), 182 deletions(-) create mode 100644 integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts create mode 100644 packages/core-flows/src/definition/payment-collection/index.ts create mode 100644 packages/core-flows/src/definition/payment-collection/steps/create-payment-session.ts create mode 100644 packages/core-flows/src/definition/payment-collection/steps/index.ts create mode 100644 packages/core-flows/src/definition/payment-collection/steps/retrieve-payment-collection.ts create mode 100644 packages/core-flows/src/definition/payment-collection/workflows/create-payment-session.ts create mode 100644 packages/core-flows/src/definition/payment-collection/workflows/index.ts create mode 100644 packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/middlewares.ts create mode 100644 packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/query-config.ts create mode 100644 packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/route.ts create mode 100644 packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/validators.ts diff --git a/integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts b/integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts new file mode 100644 index 0000000000..2660280b5d --- /dev/null +++ b/integration-tests/modules/__tests__/payment/payment-session.workflows.spec.ts @@ -0,0 +1,142 @@ +import { + createPaymentSessionsWorkflow, + createPaymentSessionsWorkflowId, +} from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPaymentModuleService, IRegionModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app" +import { getContainer } from "../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../environment-helpers/use-db" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } + +describe("Carts workflows", () => { + let dbConnection + let appContainer + let shutdownServer + let paymentModule: IPaymentModuleService + let regionModule: IRegionModuleService + let remoteLink + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + paymentModule = appContainer.resolve(ModuleRegistrationName.PAYMENT) + regionModule = appContainer.resolve(ModuleRegistrationName.REGION) + remoteLink = appContainer.resolve("remoteLink") + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + describe("createPaymentSessionWorkflow", () => { + it("should create payment sessions", async () => { + const region = await regionModule.create({ + currency_code: "usd", + name: "US", + }) + + let paymentCollection = await paymentModule.createPaymentCollections({ + currency_code: "usd", + amount: 1000, + region_id: region.id, + }) + + 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.arrayContaining([ + 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) + + workflow.appendAction("throw", createPaymentSessionsWorkflowId, { + invoke: async function failStep() { + throw new Error( + `Failed to do something after creating payment sessions` + ) + }, + }) + + const region = await regionModule.create({ + currency_code: "usd", + name: "US", + }) + + let paymentCollection = await paymentModule.createPaymentCollections({ + currency_code: "usd", + amount: 1000, + region_id: region.id, + }) + + const { errors } = await workflow.run({ + input: { + payment_collection_id: paymentCollection.id, + provider_id: "pp_system_default", + context: {}, + data: {}, + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: new Error( + `Failed to do something after creating payment sessions` + ), + }, + ]) + + const sessions = await paymentModule.listPaymentSessions({ + payment_collection_id: paymentCollection.id, + }) + + expect(sessions).toHaveLength(0) + }) + }) + }) +}) diff --git a/packages/core-flows/src/definition/index.ts b/packages/core-flows/src/definition/index.ts index fffeab915d..af9d0db6b6 100644 --- a/packages/core-flows/src/definition/index.ts +++ b/packages/core-flows/src/definition/index.ts @@ -1,6 +1,6 @@ export * from "./cart" export * from "./inventory" export * from "./line-item" +export * from "./payment-collection" export * from "./price-list" export * from "./product" - diff --git a/packages/core-flows/src/definition/payment-collection/index.ts b/packages/core-flows/src/definition/payment-collection/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/definition/payment-collection/steps/create-payment-session.ts b/packages/core-flows/src/definition/payment-collection/steps/create-payment-session.ts new file mode 100644 index 0000000000..471e59d416 --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/steps/create-payment-session.ts @@ -0,0 +1,46 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPaymentModuleService, PaymentProviderContext } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + payment_collection_id: string + provider_id: string + amount: number + currency_code: string + context?: PaymentProviderContext + data?: Record +} + +export const createPaymentSessionStepId = "create-payment-session" +export const createPaymentSessionStep = createStep( + createPaymentSessionStepId, + async (input: StepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + const session = 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, + } + ) + + return new StepResponse(session, session.id) + }, + async (createdSession, { container }) => { + if (!createdSession) { + return + } + + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + await service.deletePaymentSession(createdSession) + } +) diff --git a/packages/core-flows/src/definition/payment-collection/steps/index.ts b/packages/core-flows/src/definition/payment-collection/steps/index.ts new file mode 100644 index 0000000000..01c6dc3cff --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./create-payment-session" +export * from "./retrieve-payment-collection" + diff --git a/packages/core-flows/src/definition/payment-collection/steps/retrieve-payment-collection.ts b/packages/core-flows/src/definition/payment-collection/steps/retrieve-payment-collection.ts new file mode 100644 index 0000000000..cafbf8fc04 --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/steps/retrieve-payment-collection.ts @@ -0,0 +1,27 @@ +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-flows/src/definition/payment-collection/workflows/create-payment-session.ts b/packages/core-flows/src/definition/payment-collection/workflows/create-payment-session.ts new file mode 100644 index 0000000000..5e1bb403ec --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/workflows/create-payment-session.ts @@ -0,0 +1,47 @@ +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-flows/src/definition/payment-collection/workflows/index.ts b/packages/core-flows/src/definition/payment-collection/workflows/index.ts new file mode 100644 index 0000000000..2dd6e79700 --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/workflows/index.ts @@ -0,0 +1 @@ +export * from "./create-payment-session" diff --git a/packages/medusa/src/api-v2/store/carts/route.ts b/packages/medusa/src/api-v2/store/carts/route.ts index 90b7e1f64a..516c1908f4 100644 --- a/packages/medusa/src/api-v2/store/carts/route.ts +++ b/packages/medusa/src/api-v2/store/carts/route.ts @@ -1,22 +1,19 @@ +import { createCartWorkflow } from "@medusajs/core-flows" +import { CreateCartWorkflowInputDTO } from "@medusajs/types" +import { remoteQueryObjectFromString } from "@medusajs/utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" - -import { createCartWorkflow } from "@medusajs/core-flows" -import { CreateCartWorkflowInputDTO } from "@medusajs/types" -import { remoteQueryObjectFromString } from "@medusajs/utils" import { defaultStoreCartFields } from "../carts/query-config" export const POST = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const workflowInput = req.validatedBody - - // If the customer is logged in, we auto-assign them to the cart - if (req.auth?.actor_id) { - workflowInput.customer_id = req.auth.actor_id + const workflowInput = { + ...req.validatedBody, + customer_id: req.auth?.actor_id, } const { result, errors } = await createCartWorkflow(req.scope).run({ diff --git a/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/middlewares.ts b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/middlewares.ts new file mode 100644 index 0000000000..3b8aace995 --- /dev/null +++ b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/middlewares.ts @@ -0,0 +1,11 @@ +import { transformBody } from "../../../../../api/middlewares" +import { MiddlewareRoute } from "../../../../../loaders/helpers/routing/types" +import { StorePostPaymentCollectionsPaymentSessionReq } from "./validators" + +export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["POST"], + matcher: "/store/payment-collections/:id/payment-sessions", + middlewares: [transformBody(StorePostPaymentCollectionsPaymentSessionReq)], + }, +] diff --git a/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/query-config.ts b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/query-config.ts new file mode 100644 index 0000000000..96ad0d6a49 --- /dev/null +++ b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/query-config.ts @@ -0,0 +1,20 @@ +export const defaultStorePaymentCollectionFields = [ + "id", + "currency_code", + "amount", + "payment_sessions", + "payment_sessions.id", + "payment_sessions.amount", + "payment_sessions.provider_id", +] + +export const defaultStorePaymentCollectionRelations = ["payment_sessions"] + +export const allowedStorePaymentCollectionRelations = ["payment_sessions"] + +export const retrieveTransformQueryConfig = { + defaultFields: defaultStorePaymentCollectionFields, + defaultRelations: defaultStorePaymentCollectionRelations, + allowedRelations: allowedStorePaymentCollectionRelations, + isList: false, +} diff --git a/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/route.ts b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/route.ts new file mode 100644 index 0000000000..c6923fdb94 --- /dev/null +++ b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/route.ts @@ -0,0 +1,52 @@ +import { createPaymentSessionsWorkflow } from "@medusajs/core-flows" +import { remoteQueryObjectFromString } from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { defaultStorePaymentCollectionFields } from "./query-config" +import { StorePostPaymentCollectionsPaymentSessionReq } from "./validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + const { context, provider_id, data } = req.body + + // If the customer is logged in, we auto-assign them to the payment collection + if (req.auth?.actor_id) { + context.customer = { + ...context.customer, + id: req.auth.actor_id, + } + } + + const workflowInput = { + payment_collection_id: id, + provider_id: provider_id, + data, + context, + } + + const { errors } = await createPaymentSessionsWorkflow(req.scope).run({ + input: workflowInput as any, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve("remoteQuery") + + const query = remoteQueryObjectFromString({ + entryPoint: "payment_collection", + variables: { cart: { id } }, + fields: defaultStorePaymentCollectionFields, + }) + + const [result] = await remoteQuery(query) + + res.status(200).json({ payment_collection: result }) +} diff --git a/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/validators.ts b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/validators.ts new file mode 100644 index 0000000000..0a7c21c873 --- /dev/null +++ b/packages/medusa/src/api-v2/store/payment-collections/[id]/payment-sessions/validators.ts @@ -0,0 +1,7 @@ +import { PaymentProviderContext } from "@medusajs/types" + +export class StorePostPaymentCollectionsPaymentSessionReq { + provider_id: string + context?: PaymentProviderContext + data?: Record +} diff --git a/packages/payment-stripe/src/core/stripe-base.ts b/packages/payment-stripe/src/core/stripe-base.ts index 04fbb5f540..137691878d 100644 --- a/packages/payment-stripe/src/core/stripe-base.ts +++ b/packages/payment-stripe/src/core/stripe-base.ts @@ -4,21 +4,22 @@ import Stripe from "stripe" import { MedusaContainer, - PaymentSessionStatus, - PaymentProviderContext, PaymentProviderError, PaymentProviderSessionResponse, + PaymentSessionStatus, ProviderWebhookPayload, + UpdatePaymentProviderSession, WebhookActionResult, } from "@medusajs/types" import { - PaymentActions, AbstractPaymentProvider, - isPaymentProviderError, MedusaError, + PaymentActions, + isPaymentProviderError, } from "@medusajs/utils" import { isDefined } from "medusa-core-utils" +import { CreatePaymentProviderSession } from "@medusajs/types" import { ErrorCodes, ErrorIntentStatus, @@ -103,26 +104,20 @@ abstract class StripeBase extends AbstractPaymentProvider { } async initiatePayment( - context: PaymentProviderContext + input: CreatePaymentProviderSession ): Promise { const intentRequestData = this.getPaymentIntentOptions() - const { - email, - context: cart_context, - currency_code, - amount, - resource_id, - customer, - } = context + const { email, extra, resource_id, customer } = input.context + const { currency_code, amount } = input - const description = (cart_context.payment_description ?? + const description = (extra?.payment_description ?? this.options_?.payment_description) as string const intentRequest: Stripe.PaymentIntentCreateParams = { description, amount: Math.round(amount), currency: currency_code, - metadata: { resource_id }, + metadata: { resource_id: resource_id ?? "Medusa Payment" }, capture_method: this.options_.capture ? "automatic" : "manual", ...intentRequestData, } @@ -149,9 +144,9 @@ abstract class StripeBase extends AbstractPaymentProvider { intentRequest.customer = stripeCustomer.id } - let session_data + let sessionData try { - session_data = (await this.stripe_.paymentIntents.create( + sessionData = (await this.stripe_.paymentIntents.create( intentRequest )) as unknown as Record } catch (e) { @@ -162,7 +157,7 @@ abstract class StripeBase extends AbstractPaymentProvider { } return { - data: session_data, + data: sessionData, // TODO: REVISIT // update_requests: customer?.metadata?.stripe_id // ? undefined @@ -260,13 +255,14 @@ abstract class StripeBase extends AbstractPaymentProvider { } async updatePayment( - context: PaymentProviderContext + input: UpdatePaymentProviderSession ): Promise { - const { amount, customer, payment_session_data } = context - const stripeId = customer?.metadata?.stripe_id + const { context, data, amount } = input - if (stripeId !== payment_session_data.customer) { - const result = await this.initiatePayment(context) + const stripeId = context.customer?.metadata?.stripe_id + + if (stripeId !== data.customer) { + const result = await this.initiatePayment(input) if (isPaymentProviderError(result)) { return this.buildError( "An error occurred in updatePayment during the initiate of the new payment for the new customer", @@ -276,12 +272,12 @@ abstract class StripeBase extends AbstractPaymentProvider { return result } else { - if (amount && payment_session_data.amount === Math.round(amount)) { - return { data: payment_session_data } + if (amount && data.amount === Math.round(amount)) { + return { data } } try { - const id = payment_session_data.id as string + const id = data.id as string const sessionData = (await this.stripe_.paymentIntents.update(id, { amount: Math.round(amount), })) as unknown as PaymentProviderSessionResponse["data"] diff --git a/packages/payment/integration-tests/__fixtures__/data.ts b/packages/payment/integration-tests/__fixtures__/data.ts index 4876195d13..97598ff440 100644 --- a/packages/payment/integration-tests/__fixtures__/data.ts +++ b/packages/payment/integration-tests/__fixtures__/data.ts @@ -25,21 +25,21 @@ export const defaultPaymentSessionData = [ amount: 100, currency_code: "usd", provider_id: "pp_system_default", - payment_collection: "pay-col-id-1", + payment_collection_id: "pay-col-id-1", }, { id: "pay-sess-id-2", amount: 100, currency_code: "usd", provider_id: "pp_system_default", - payment_collection: "pay-col-id-2", + payment_collection_id: "pay-col-id-2", }, { id: "pay-sess-id-3", amount: 100, currency_code: "usd", provider_id: "pp_system_default", - payment_collection: "pay-col-id-2", + payment_collection_id: "pay-col-id-2", }, ] @@ -48,7 +48,7 @@ export const defaultPaymentData = [ id: "pay-id-1", amount: 100, currency_code: "usd", - payment_collection: "pay-col-id-1", + payment_collection_id: "pay-col-id-1", payment_session: "pay-sess-id-1", provider_id: "pp_system_default", authorized_amount: 100, @@ -59,7 +59,7 @@ export const defaultPaymentData = [ amount: 100, authorized_amount: 100, currency_code: "usd", - payment_collection: "pay-col-id-2", + payment_collection_id: "pay-col-id-2", payment_session: "pay-sess-id-2", provider_id: "pp_system_default", data: {}, diff --git a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index ab47dde7b6..6c88f06f24 100644 --- a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -21,17 +21,9 @@ moduleIntegrationTestRunner({ }: SuiteOptions) => { describe("Payment Module Service", () => { describe("Payment Flow", () => { - beforeEach(async () => { - const repositoryManager = MikroOrmWrapper.forkManager() - - await createPaymentCollections(repositoryManager) - await createPaymentSessions(repositoryManager) - await createPayments(repositoryManager) - }) - it("complete payment flow successfully", async () => { let paymentCollection = await service.createPaymentCollections({ - currency_code: "USD", + currency_code: "usd", amount: 200, region_id: "reg_123", }) @@ -40,11 +32,11 @@ moduleIntegrationTestRunner({ paymentCollection.id, { provider_id: "pp_system_default", - providerContext: { - amount: 200, - currency_code: "USD", - payment_session_data: {}, - context: {}, + amount: 200, + currency_code: "usd", + data: {}, + context: { + extra: {}, customer: {}, billing_address: {}, email: "test@test.test.com", @@ -72,7 +64,7 @@ moduleIntegrationTestRunner({ expect(paymentCollection).toEqual( expect.objectContaining({ id: expect.any(String), - currency_code: "USD", + currency_code: "usd", amount: 200, // TODO // authorized_amount: 200, @@ -83,7 +75,7 @@ moduleIntegrationTestRunner({ payment_sessions: [ expect.objectContaining({ id: expect.any(String), - currency_code: "USD", + currency_code: "usd", amount: 200, provider_id: "pp_system_default", status: "authorized", @@ -94,7 +86,7 @@ moduleIntegrationTestRunner({ expect.objectContaining({ id: expect.any(String), amount: 200, - currency_code: "USD", + currency_code: "usd", provider_id: "pp_system_default", captures: [ expect.objectContaining({ @@ -336,11 +328,11 @@ moduleIntegrationTestRunner({ it("should create a payment session successfully", async () => { await service.createPaymentSession("pay-col-id-1", { provider_id: "pp_system_default", - providerContext: { - amount: 200, - currency_code: "usd", - payment_session_data: {}, - context: {}, + amount: 200, + currency_code: "usd", + data: {}, + context: { + extra: {}, customer: {}, billing_address: {}, email: "test@test.test.com", @@ -377,11 +369,11 @@ moduleIntegrationTestRunner({ it("should update a payment session successfully", async () => { let session = await service.createPaymentSession("pay-col-id-1", { provider_id: "pp_system_default", - providerContext: { - amount: 200, - currency_code: "usd", - payment_session_data: {}, - context: {}, + amount: 200, + currency_code: "usd", + data: {}, + context: { + extra: {}, customer: {}, billing_address: {}, email: "test@test.test.com", @@ -391,15 +383,15 @@ moduleIntegrationTestRunner({ session = await service.updatePaymentSession({ id: session.id, - providerContext: { - amount: 200, - currency_code: "eur", + amount: 200, + currency_code: "eur", + data: {}, + context: { resource_id: "res_id", - context: {}, + extra: {}, customer: {}, billing_address: {}, email: "new@test.tsst", - payment_session_data: {}, }, }) @@ -424,11 +416,11 @@ moduleIntegrationTestRunner({ const session = await service.createPaymentSession(collection.id, { provider_id: "pp_system_default", - providerContext: { - amount: 100, - currency_code: "usd", - payment_session_data: {}, - context: {}, + amount: 100, + currency_code: "usd", + data: {}, + context: { + extra: {}, resource_id: "test", email: "test@test.com", billing_address: {}, @@ -447,7 +439,6 @@ moduleIntegrationTestRunner({ amount: 100, currency_code: "usd", provider_id: "pp_system_default", - refunds: [], captures: [], data: {}, @@ -458,9 +449,7 @@ moduleIntegrationTestRunner({ deleted_at: null, captured_at: null, canceled_at: null, - payment_collection: expect.objectContaining({ - id: expect.any(String), - }), + payment_collection_id: expect.any(String), payment_session: expect.objectContaining({ id: expect.any(String), updated_at: expect.any(Date), @@ -472,6 +461,10 @@ moduleIntegrationTestRunner({ data: {}, status: "authorized", authorized_at: expect.any(Date), + payment_collection: expect.objectContaining({ + id: expect.any(String), + }), + payment_collection_id: expect.any(String), }), }) ) diff --git a/packages/payment/package.json b/packages/payment/package.json index c169318dde..bf972fc088 100644 --- a/packages/payment/package.json +++ b/packages/payment/package.json @@ -51,6 +51,7 @@ "@medusajs/modules-sdk": "^1.12.5", "@medusajs/types": "^1.11.9", "@medusajs/utils": "^1.11.2", + "@medusajs/workflows-sdk": "workspace:^", "@mikro-orm/core": "5.9.7", "@mikro-orm/migrations": "5.9.7", "@mikro-orm/postgresql": "5.9.7", diff --git a/packages/payment/src/migrations/Migration20240225134525.ts b/packages/payment/src/migrations/Migration20240225134525.ts index e91ce8afba..2601c03cbe 100644 --- a/packages/payment/src/migrations/Migration20240225134525.ts +++ b/packages/payment/src/migrations/Migration20240225134525.ts @@ -22,6 +22,8 @@ export class Migration20240225134525 extends Migration { ALTER TABLE IF EXISTS "payment_provider" ADD COLUMN IF NOT EXISTS "is_enabled" BOOLEAN NOT NULL DEFAULT TRUE; ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "payment_collection_id" TEXT NOT NULL; + ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "currency_code" TEXT NOT NULL; + ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "authorized_at" TEXT NULL; ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "payment_authorized_at" TIMESTAMPTZ NULL; ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL; ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; @@ -31,6 +33,7 @@ export class Migration20240225134525 extends Migration { ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "provider_id" TEXT NOT NULL; ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL; ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; + ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "payment_session_id" TEXT NOT NULL; ALTER TABLE IF EXISTS "refund" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL; ALTER TABLE IF EXISTS "refund" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; @@ -176,7 +179,7 @@ export class Migration20240225134525 extends Migration { "captured_at" TIMESTAMPTZ NULL, "canceled_at" TIMESTAMPTZ NULL, "payment_collection_id" TEXT NOT NULL, - "session_id" TEXT NOT NULL, + "payment_session_id" TEXT NOT NULL, "metadata" JSONB NULL, CONSTRAINT "payment_pkey" PRIMARY KEY ("id") ); diff --git a/packages/payment/src/models/payment-collection.ts b/packages/payment/src/models/payment-collection.ts index 62468ce612..295986e452 100644 --- a/packages/payment/src/models/payment-collection.ts +++ b/packages/payment/src/models/payment-collection.ts @@ -84,12 +84,12 @@ export default class PaymentCollection { payment_providers = new Collection(this) @OneToMany(() => PaymentSession, (ps) => ps.payment_collection, { - cascade: [Cascade.REMOVE], + cascade: [Cascade.PERSIST, "soft-remove"] as any, }) payment_sessions = new Collection(this) @OneToMany(() => Payment, (payment) => payment.payment_collection, { - cascade: [Cascade.REMOVE], + cascade: [Cascade.PERSIST, "soft-remove"] as any, }) payments = new Collection(this) diff --git a/packages/payment/src/models/payment-session.ts b/packages/payment/src/models/payment-session.ts index 17dd997be7..d9b2019ff6 100644 --- a/packages/payment/src/models/payment-session.ts +++ b/packages/payment/src/models/payment-session.ts @@ -54,19 +54,24 @@ export default class PaymentSession { }) authorized_at: Date | null = null + @ManyToOne(() => PaymentCollection, { + persist: false, + }) + payment_collection: PaymentCollection + @ManyToOne({ entity: () => PaymentCollection, + columnType: "text", index: "IDX_payment_session_payment_collection_id", fieldName: "payment_collection_id", - onDelete: "cascade", + mapToPk: true, }) - payment_collection!: PaymentCollection + payment_collection_id: string @OneToOne({ entity: () => Payment, - mappedBy: (payment) => payment.payment_session, - cascade: ["soft-remove"] as any, nullable: true, + mappedBy: "payment_session", }) payment?: Payment | null diff --git a/packages/payment/src/models/payment.ts b/packages/payment/src/models/payment.ts index 8c6353eabc..5ad1cd429c 100644 --- a/packages/payment/src/models/payment.ts +++ b/packages/payment/src/models/payment.ts @@ -109,18 +109,26 @@ export default class Payment { captures = new Collection(this) @ManyToOne({ + entity: () => PaymentCollection, + persist: false, + }) + payment_collection: PaymentCollection + + @ManyToOne({ + entity: () => PaymentCollection, + columnType: "text", index: "IDX_payment_payment_collection_id", fieldName: "payment_collection_id", - onDelete: "cascade", + mapToPk: true, }) - payment_collection!: PaymentCollection + payment_collection_id: string @OneToOne({ owner: true, - fieldName: "session_id", + fieldName: "payment_session_id", index: "IDX_payment_payment_session_id", }) - payment_session!: PaymentSession + payment_session: PaymentSession /** COMPUTED PROPERTIES START **/ diff --git a/packages/payment/src/providers/system.ts b/packages/payment/src/providers/system.ts index 2675dd74fd..c7d4febdb8 100644 --- a/packages/payment/src/providers/system.ts +++ b/packages/payment/src/providers/system.ts @@ -1,5 +1,5 @@ import { - PaymentProviderContext, + CreatePaymentProviderSession, PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, @@ -21,7 +21,7 @@ export class SystemProviderService extends AbstractPaymentProvider { } async initiatePayment( - context: PaymentProviderContext + context: CreatePaymentProviderSession ): Promise { return { data: {} } } diff --git a/packages/payment/src/services/payment-module.ts b/packages/payment/src/services/payment-module.ts index 6021cbf488..c620c7d722 100644 --- a/packages/payment/src/services/payment-module.ts +++ b/packages/payment/src/services/payment-module.ts @@ -22,6 +22,7 @@ import { UpdatePaymentSessionDTO, } from "@medusajs/types" import { + InjectManager, InjectTransactionManager, MedusaContext, MedusaError, @@ -35,7 +36,6 @@ import { PaymentSession, Refund, } from "@models" - import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import PaymentProviderService from "./payment-provider" @@ -49,7 +49,7 @@ type InjectedDependencies = { paymentProviderService: PaymentProviderService } -const generateMethodForModels = [PaymentCollection, Payment] +const generateMethodForModels = [PaymentCollection, Payment, PaymentSession] export default class PaymentModuleService< TPaymentCollection extends PaymentCollection = PaymentCollection, @@ -201,44 +201,67 @@ export default class PaymentModuleService< ) } - @InjectTransactionManager("baseRepository_") + @InjectManager("baseRepository_") async createPaymentSession( + paymentCollectionId: string, + input: CreatePaymentSessionDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + let paymentSession: PaymentSession + + try { + const providerSessionSession = + await this.paymentProviderService_.createSession(input.provider_id, { + context: input.context ?? {}, + amount: input.amount, + currency_code: input.currency_code, + }) + + input.data = { + ...input.data, + ...providerSessionSession, + } + + paymentSession = await this.createPaymentSession_( + paymentCollectionId, + input, + sharedContext + ) + } catch (error) { + // In case the session is created at the provider, but fails to be created in Medusa, + // we catch the error and delete the session at the provider and rethrow. + await this.paymentProviderService_.deleteSession({ + provider_id: input.provider_id, + data: input.data, + }) + + throw error + } + + return await this.baseRepository_.serialize(paymentSession, { + populate: true, + }) + } + + @InjectTransactionManager("baseRepository_") + async createPaymentSession_( paymentCollectionId: string, data: CreatePaymentSessionDTO, @MedusaContext() sharedContext?: Context - ): Promise { - const created = await this.paymentSessionService_.create( + ): Promise { + const paymentSession = await this.paymentSessionService_.create( { + payment_collection_id: paymentCollectionId, provider_id: data.provider_id, - amount: data.providerContext.amount, - currency_code: data.providerContext.currency_code, - payment_collection: paymentCollectionId, + amount: data.amount, + currency_code: data.currency_code, + context: data.context, + data: data.data, }, sharedContext ) - try { - const sessionData = await this.paymentProviderService_.createSession( - data.provider_id, - { - ...data.providerContext, - resource_id: created.id, - } - ) - - await this.paymentSessionService_.update( - { - id: created.id, - data: sessionData, - }, - sharedContext - ) - - return await this.baseRepository_.serialize(created, { populate: true }) - } catch (e) { - await this.paymentSessionService_.delete([created.id], sharedContext) - throw e - } + return paymentSession } @InjectTransactionManager("baseRepository_") @@ -252,17 +275,12 @@ export default class PaymentModuleService< sharedContext ) - const sessionData = await this.paymentProviderService_.updateSession( - session.provider_id, - data.providerContext - ) - const updated = await this.paymentSessionService_.update( { id: session.id, - amount: data.providerContext.amount, - currency_code: data.providerContext.currency_code, - data: sessionData, + amount: data.amount, + currency_code: data.currency_code, + data: data.data, }, sharedContext ) @@ -298,8 +316,14 @@ export default class PaymentModuleService< const session = await this.paymentSessionService_.retrieve( id, { - select: ["id", "data", "provider_id", "amount", "currency_code"], - relations: ["payment_collection"], + select: [ + "id", + "data", + "provider_id", + "amount", + "currency_code", + "payment_collection_id", + ], }, sharedContext ) @@ -348,7 +372,7 @@ export default class PaymentModuleService< amount: session.amount, currency_code: session.currency_code, payment_session: session.id, - payment_collection: session.payment_collection!.id, + payment_collection_id: session.payment_collection_id, provider_id: session.provider_id, // customer_id: context.customer.id, data, diff --git a/packages/payment/src/services/payment-provider.ts b/packages/payment/src/services/payment-provider.ts index af3a000ca9..e9c51baca3 100644 --- a/packages/payment/src/services/payment-provider.ts +++ b/packages/payment/src/services/payment-provider.ts @@ -1,18 +1,17 @@ -import { EOL } from "os" -import { isDefined, MedusaError } from "medusa-core-utils" import { Context, CreatePaymentProviderDTO, + CreatePaymentProviderSession, DAL, InternalModuleDeclaration, IPaymentProvider, PaymentProviderAuthorizeResponse, - PaymentProviderContext, PaymentProviderDataInput, PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, ProviderWebhookPayload, + UpdatePaymentProviderSession, WebhookActionResult, } from "@medusajs/types" import { @@ -21,8 +20,9 @@ import { isPaymentProviderError, MedusaContext, } from "@medusajs/utils" - import { PaymentProvider } from "@models" +import { MedusaError } from "medusa-core-utils" +import { EOL } from "os" type InjectedDependencies = { paymentProviderRepository: DAL.RepositoryService @@ -70,20 +70,10 @@ export default class PaymentProviderService { async createSession( providerId: string, - sessionInput: PaymentProviderContext + sessionInput: CreatePaymentProviderSession ): Promise { const provider = this.retrieveProvider(providerId) - if ( - !isDefined(sessionInput.currency_code) || - !isDefined(sessionInput.amount) - ) { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "`currency_code` and `amount` are required to create payment session." - ) - } - const paymentResponse = await provider.initiatePayment(sessionInput) if (isPaymentProviderError(paymentResponse)) { @@ -95,7 +85,7 @@ export default class PaymentProviderService { async updateSession( providerId: string, - sessionInput: PaymentProviderContext + sessionInput: UpdatePaymentProviderSession ): Promise | undefined> { const provider = this.retrieveProvider(providerId) diff --git a/packages/types/src/payment/common.ts b/packages/types/src/payment/common.ts index 590825e7ab..b174fee5b1 100644 --- a/packages/types/src/payment/common.ts +++ b/packages/types/src/payment/common.ts @@ -169,6 +169,19 @@ export interface FilterablePaymentCollectionProps updated_at?: OperatorMap } +export interface FilterablePaymentSessionProps + extends BaseFilterable { + id?: string | string[] + currency_code?: string | string[] + amount?: number | OperatorMap + provider_id?: string | string[] + payment_collection_id?: string | string[] + region_id?: string | string[] | OperatorMap + created_at?: OperatorMap + updated_at?: OperatorMap + deleted_at?: OperatorMap +} + /* ********** PAYMENT ********** */ export interface PaymentDTO { /** diff --git a/packages/types/src/payment/mutations.ts b/packages/types/src/payment/mutations.ts index bb1ad497c7..621b77e38b 100644 --- a/packages/types/src/payment/mutations.ts +++ b/packages/types/src/payment/mutations.ts @@ -186,9 +186,21 @@ export interface CreatePaymentSessionDTO { */ provider_id: string /** - * The provider's context. + * The selected currency code. */ - providerContext: Omit + currency_code: string + /** + * The payment's amount. + */ + amount: number + /** + * The value of the payment session's `data` field. + */ + data: Record + /** + * The payment session's context. + */ + context?: PaymentProviderContext } /** @@ -199,10 +211,22 @@ export interface UpdatePaymentSessionDTO { * The payment session's ID. */ id: string + /** + * The value of the payment session's `data` field. + */ + data: Record + /** + * The selected currency code. + */ + currency_code: string + /** + * The payment's amount. + */ + amount: number /** * The payment session's context. */ - providerContext: PaymentProviderContext + context?: PaymentProviderContext } /** diff --git a/packages/types/src/payment/provider.ts b/packages/types/src/payment/provider.ts index b48dcef76b..a8b5feb61d 100644 --- a/packages/types/src/payment/provider.ts +++ b/packages/types/src/payment/provider.ts @@ -1,6 +1,6 @@ -import { PaymentSessionStatus } from "./common" -import { CustomerDTO } from "../customer" import { AddressDTO } from "../address" +import { CustomerDTO } from "../customer" +import { PaymentSessionStatus } from "./common" import { ProviderWebhookPayload } from "./mutations" export type PaymentAddressDTO = Partial @@ -43,31 +43,31 @@ export type PaymentProviderContext = { * The customer's email. */ email?: string - /** - * The selected currency code. - */ - currency_code: string - /** - * The payment's amount. - */ - amount: number /** * The ID of the resource the payment is associated with i.e. the ID of the PaymentSession in Medusa */ - resource_id: string + resource_id?: string /** * The customer associated with this payment. */ customer?: PaymentCustomerDTO /** - * The context. + * The extra fields specific to the provider session. */ - context: { payment_description?: string } & Record - /** - * If the payment session hasn't been created or initiated yet, it'll be an empty object. - * If the payment session exists, it'll be the value of the payment session's `data` field. - */ - payment_session_data: Record + extra?: Record +} + +export type CreatePaymentProviderSession = { + context: PaymentProviderContext + amount: number + currency_code: string +} + +export type UpdatePaymentProviderSession = { + context: PaymentProviderContext + data: Record + amount: number + currency_code: string } /** @@ -142,21 +142,21 @@ export interface IPaymentProvider { /** * Make calls to the third-party provider to initialize the payment. For example, in Stripe this method is used to create a Payment Intent for the customer. * - * @param {PaymentProviderContext} context - The context of the payment. + * @param {CreatePaymentProviderSession} context * @returns {Promise} Either the payment's data or an error object. */ initiatePayment( - context: PaymentProviderContext + data: CreatePaymentProviderSession ): Promise /** * This method is used to update the payment session. * - * @param {PaymentProviderContext} context - The context of the payment. + * @param {UpdatePaymentProviderSession} context * @returns {Promise} Either the payment's data or an error object. */ updatePayment( - context: PaymentProviderContext + context: UpdatePaymentProviderSession ): Promise /** diff --git a/packages/types/src/payment/service.ts b/packages/types/src/payment/service.ts index 9208a869c5..2cb734631d 100644 --- a/packages/types/src/payment/service.ts +++ b/packages/types/src/payment/service.ts @@ -4,6 +4,7 @@ import { Context } from "../shared-context" import { FilterablePaymentCollectionProps, FilterablePaymentProps, + FilterablePaymentSessionProps, PaymentCollectionDTO, PaymentDTO, PaymentSessionDTO, @@ -387,6 +388,12 @@ export interface IPaymentModuleService extends IModuleService { */ cancelPayment(paymentId: string, sharedContext?: Context): Promise + listPaymentSessions( + filters?: FilterablePaymentSessionProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + /** * This method creates providers on load. * diff --git a/packages/utils/src/payment/abstract-payment-provider.ts b/packages/utils/src/payment/abstract-payment-provider.ts index 08e8d3e326..c6d082818b 100644 --- a/packages/utils/src/payment/abstract-payment-provider.ts +++ b/packages/utils/src/payment/abstract-payment-provider.ts @@ -1,12 +1,13 @@ import { + CreatePaymentProviderSession, IPaymentProvider, MedusaContainer, - PaymentProviderContext, PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, ProviderWebhookPayload, - WebhookActionResult, + UpdatePaymentProviderSession, + WebhookActionResult } from "@medusajs/types" export abstract class AbstractPaymentProvider> @@ -107,7 +108,7 @@ export abstract class AbstractPaymentProvider> ): Promise abstract initiatePayment( - context: PaymentProviderContext + context: CreatePaymentProviderSession ): Promise abstract deletePayment( @@ -128,7 +129,7 @@ export abstract class AbstractPaymentProvider> ): Promise abstract updatePayment( - context: PaymentProviderContext + context: UpdatePaymentProviderSession ): Promise abstract getWebhookActionAndData( diff --git a/yarn.lock b/yarn.lock index ffb42f6d9f..780dfbf567 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8642,6 +8642,7 @@ __metadata: "@medusajs/modules-sdk": ^1.12.5 "@medusajs/types": ^1.11.9 "@medusajs/utils": ^1.11.2 + "@medusajs/workflows-sdk": "workspace:^" "@mikro-orm/cli": 5.9.7 "@mikro-orm/core": 5.9.7 "@mikro-orm/migrations": 5.9.7 @@ -9126,7 +9127,7 @@ __metadata: languageName: unknown linkType: soft -"@medusajs/workflows-sdk@^0.1.2, @medusajs/workflows-sdk@^0.1.3, @medusajs/workflows-sdk@workspace:packages/workflows-sdk": +"@medusajs/workflows-sdk@^0.1.2, @medusajs/workflows-sdk@^0.1.3, @medusajs/workflows-sdk@workspace:^, @medusajs/workflows-sdk@workspace:packages/workflows-sdk": version: 0.0.0-use.local resolution: "@medusajs/workflows-sdk@workspace:packages/workflows-sdk" dependencies: