diff --git a/.changeset/swift-spoons-rescue.md b/.changeset/swift-spoons-rescue.md new file mode 100644 index 0000000000..e0b81da54d --- /dev/null +++ b/.changeset/swift-spoons-rescue.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(types, utils): payment module - provider service diff --git a/packages/payment/integration-tests/__fixtures__/data.ts b/packages/payment/integration-tests/__fixtures__/data.ts index 8dad208fde..0eab65b782 100644 --- a/packages/payment/integration-tests/__fixtures__/data.ts +++ b/packages/payment/integration-tests/__fixtures__/data.ts @@ -24,21 +24,21 @@ export const defaultPaymentSessionData = [ id: "pay-sess-id-1", amount: 100, currency_code: "usd", - provider_id: "manual", + provider_id: "system", payment_collection: "pay-col-id-1", }, { id: "pay-sess-id-2", amount: 100, currency_code: "usd", - provider_id: "manual", + provider_id: "system", payment_collection: "pay-col-id-2", }, { id: "pay-sess-id-3", amount: 100, currency_code: "usd", - provider_id: "manual", + provider_id: "system", payment_collection: "pay-col-id-2", }, ] @@ -50,7 +50,7 @@ export const defaultPaymentData = [ currency_code: "usd", payment_collection: "pay-col-id-1", payment_session: "pay-sess-id-1", - provider_id: "manual", + provider_id: "system", authorized_amount: 100, data: {}, }, @@ -61,7 +61,7 @@ export const defaultPaymentData = [ currency_code: "usd", payment_collection: "pay-col-id-2", payment_session: "pay-sess-id-2", - provider_id: "manual", + provider_id: "system", 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 b671f78c42..2c3a89286f 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 @@ -1,50 +1,35 @@ import { IPaymentModuleService } from "@medusajs/types" -import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initModules } from "medusa-test-utils" +import { Modules } from "@medusajs/modules-sdk" -import { initialize } from "../../../../src" -import { DB_URL, MikroOrmWrapper } from "../../../utils" +import { MikroOrmWrapper } from "../../../utils" import { createPaymentCollections, createPaymentSessions, createPayments, } from "../../../__fixtures__" import { getInitModuleConfig } from "../../../utils/get-init-module-config" -import { initModules } from "medusa-test-utils" -import { Modules } from "@medusajs/modules-sdk" jest.setTimeout(30000) describe("Payment Module Service", () => { - let service: IPaymentModuleService - - describe("PaymentCollection", () => { - let repositoryManager: SqlEntityManager + describe("Payment Flow", () => { + let service: IPaymentModuleService let shutdownFunc: () => Promise - beforeAll(async () => { - const initModulesConfig = getInitModuleConfig() - - const { medusaApp, shutdown } = await initModules(initModulesConfig) - - service = medusaApp.modules[Modules.PAYMENT] - - shutdownFunc = shutdown - }) - afterAll(async () => { await shutdownFunc() }) beforeEach(async () => { await MikroOrmWrapper.setupDatabase() - repositoryManager = await MikroOrmWrapper.forkManager() + const repositoryManager = await MikroOrmWrapper.forkManager() - service = await initialize({ - database: { - clientUrl: DB_URL, - schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA, - }, - }) + const initModulesConfig = getInitModuleConfig() + const { medusaApp, shutdown } = await initModules(initModulesConfig) + service = medusaApp.modules[Modules.PAYMENT] + + shutdownFunc = shutdown await createPaymentCollections(repositoryManager) await createPaymentSessions(repositoryManager) @@ -53,12 +38,120 @@ describe("Payment Module Service", () => { afterEach(async () => { await MikroOrmWrapper.clearDatabase() + await shutdownFunc() + }) + it("complete payment flow successfully", async () => { + let paymentCollection = await service.createPaymentCollections({ + currency_code: "USD", + amount: 200, + region_id: "reg_123", + }) + + const paymentSession = await service.createPaymentSession( + paymentCollection.id, + { + provider_id: "system", + providerContext: { + amount: 200, + currency_code: "USD", + payment_session_data: {}, + context: {}, + customer: {}, + billing_address: {}, + email: "test@test.test.com", + resource_id: "cart_test", + }, + } + ) + + const payment = await service.authorizePaymentSession( + paymentSession.id, + {} + ) + + await service.capturePayment({ + amount: 200, + payment_id: payment.id, + }) + + await service.completePaymentCollections(paymentCollection.id) + + paymentCollection = await service.retrievePaymentCollection( + paymentCollection.id, + { relations: ["payment_sessions", "payments.captures"] } + ) + + expect(paymentCollection).toEqual( + expect.objectContaining({ + id: expect.any(String), + currency_code: "USD", + amount: 200, + // TODO + // authorized_amount: 200, + // status: "authorized", + region_id: "reg_123", + deleted_at: null, + completed_at: expect.any(Date), + payment_sessions: [ + expect.objectContaining({ + id: expect.any(String), + currency_code: "USD", + amount: 200, + provider_id: "system", + status: "authorized", + authorized_at: expect.any(Date), + }), + ], + payments: [ + expect.objectContaining({ + id: expect.any(String), + amount: 200, + currency_code: "USD", + provider_id: "system", + captures: [ + expect.objectContaining({ + amount: 200, + }), + ], + }), + ], + }) + ) + }) + }) + + describe("PaymentCollection", () => { + let service: IPaymentModuleService + let shutdownFunc: () => Promise + + afterAll(async () => { + await shutdownFunc() + }) + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + const repositoryManager = await MikroOrmWrapper.forkManager() + + const initModulesConfig = getInitModuleConfig() + const { medusaApp, shutdown } = await initModules(initModulesConfig) + service = medusaApp.modules[Modules.PAYMENT] + + shutdownFunc = shutdown + + await createPaymentCollections(repositoryManager) + await createPaymentSessions(repositoryManager) + await createPayments(repositoryManager) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + await shutdownFunc() }) describe("create", () => { it("should throw an error when required params are not passed", async () => { let error = await service - .createPaymentCollection([ + .createPaymentCollections([ { amount: 200, region_id: "req_123", @@ -71,7 +164,7 @@ describe("Payment Module Service", () => { ) error = await service - .createPaymentCollection([ + .createPaymentCollections([ { currency_code: "USD", region_id: "req_123", @@ -84,7 +177,7 @@ describe("Payment Module Service", () => { ) error = await service - .createPaymentCollection([ + .createPaymentCollections([ { currency_code: "USD", amount: 200, @@ -99,7 +192,7 @@ describe("Payment Module Service", () => { it("should create a payment collection successfully", async () => { const [createdPaymentCollection] = - await service.createPaymentCollection([ + await service.createPaymentCollections([ { currency_code: "USD", amount: 200, region_id: "reg_123" }, ]) @@ -220,10 +313,10 @@ describe("Payment Module Service", () => { describe("update", () => { it("should update a Payment Collection", async () => { - await service.updatePaymentCollection({ + await service.updatePaymentCollections({ id: "pay-col-id-2", currency_code: "eur", - authorized_amount: 200, + region_id: "reg-2", }) const collection = await service.retrievePaymentCollection( @@ -233,84 +326,48 @@ describe("Payment Module Service", () => { expect(collection).toEqual( expect.objectContaining({ id: "pay-col-id-2", - authorized_amount: 200, + region_id: "reg-2", currency_code: "eur", }) ) }) }) + + describe("complete", () => { + it("should complete a Payment Collection", async () => { + await service.completePaymentCollections("pay-col-id-1") + + const collection = await service.retrievePaymentCollection( + "pay-col-id-1" + ) + + expect(collection).toEqual( + expect.objectContaining({ + id: "pay-col-id-1", + completed_at: expect.any(Date), + }) + ) + }) + }) }) describe("PaymentSession", () => { - let repositoryManager: SqlEntityManager + let service: IPaymentModuleService + let shutdownFunc: () => Promise + + afterAll(async () => { + await shutdownFunc() + }) beforeEach(async () => { await MikroOrmWrapper.setupDatabase() - repositoryManager = await MikroOrmWrapper.forkManager() + const repositoryManager = await MikroOrmWrapper.forkManager() - service = await initialize({ - database: { - clientUrl: DB_URL, - schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA, - }, - }) + const initModulesConfig = getInitModuleConfig() + const { medusaApp, shutdown } = await initModules(initModulesConfig) + service = medusaApp.modules[Modules.PAYMENT] - await createPaymentCollections(repositoryManager) - await createPaymentSessions(repositoryManager) - }) - - afterEach(async () => { - await MikroOrmWrapper.clearDatabase() - }) - - describe("create", () => { - it("should create a payment session successfully", async () => { - const paymentCollection = await service.createPaymentSession( - "pay-col-id-1", - { - amount: 200, - provider_id: "manual", - currency_code: "usd", - } - ) - - expect(paymentCollection).toEqual( - expect.objectContaining({ - id: "pay-col-id-1", - status: "not_paid", - payment_sessions: expect.arrayContaining([ - { - id: expect.any(String), - data: null, - status: "pending", - authorized_at: null, - currency_code: "usd", - amount: 200, - provider_id: "manual", - payment_collection: expect.objectContaining({ - id: paymentCollection.id, - }), - }, - ]), - }) - ) - }) - }) - }) - - describe("Payment", () => { - let repositoryManager: SqlEntityManager - - beforeEach(async () => { - await MikroOrmWrapper.setupDatabase() - repositoryManager = await MikroOrmWrapper.forkManager() - - service = await initialize({ - database: { - clientUrl: DB_URL, - schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA, - }, - }) + shutdownFunc = shutdown await createPaymentCollections(repositoryManager) await createPaymentSessions(repositoryManager) @@ -319,61 +376,197 @@ describe("Payment Module Service", () => { afterEach(async () => { await MikroOrmWrapper.clearDatabase() + await shutdownFunc() }) describe("create", () => { - it("should create a payment successfully", async () => { - let paymentCollection = await service.createPaymentCollection({ - currency_code: "usd", - amount: 200, - region_id: "reg", + it("should create a payment session successfully", async () => { + await service.createPaymentSession("pay-col-id-1", { + provider_id: "system", + providerContext: { + amount: 200, + currency_code: "usd", + payment_session_data: {}, + context: {}, + customer: {}, + billing_address: {}, + email: "test@test.test.com", + resource_id: "cart_test", + }, }) - paymentCollection = await service.createPaymentSession( - paymentCollection.id, - { - amount: 200, - provider_id: "manual", - currency_code: "usd", - } + const paymentCollection = await service.retrievePaymentCollection( + "pay-col-id-1", + { relations: ["payment_sessions"] } ) - const createdPayment = await service.createPayment({ - data: {}, - amount: 200, - provider_id: "manual", - currency_code: "usd", - payment_collection_id: paymentCollection.id, - payment_session_id: paymentCollection.payment_sessions![0].id, + expect(paymentCollection).toEqual( + expect.objectContaining({ + id: "pay-col-id-1", + status: "not_paid", + payment_sessions: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + data: {}, + status: "pending", + authorized_at: null, + currency_code: "usd", + amount: 200, + provider_id: "system", + }), + ]), + }) + ) + }) + }) + + describe("update", () => { + it("should update a payment session successfully", async () => { + let session = await service.createPaymentSession("pay-col-id-1", { + provider_id: "system", + providerContext: { + amount: 200, + currency_code: "usd", + payment_session_data: {}, + context: {}, + customer: {}, + billing_address: {}, + email: "test@test.test.com", + resource_id: "cart_test", + }, }) - expect(createdPayment).toEqual( + session = await service.updatePaymentSession({ + id: session.id, + providerContext: { + amount: 200, + currency_code: "eur", + resource_id: "res_id", + context: {}, + customer: {}, + billing_address: {}, + email: "new@test.tsst", + payment_session_data: {}, + }, + }) + + expect(session).toEqual( expect.objectContaining({ id: expect.any(String), - authorized_amount: null, + status: "pending", + currency_code: "eur", + amount: 200, + }) + ) + }) + }) + + describe("authorize", () => { + it("should authorize a payment session", async () => { + const collection = await service.createPaymentCollections({ + amount: 200, + region_id: "test-region", + currency_code: "usd", + }) + + const session = await service.createPaymentSession(collection.id, { + provider_id: "system", + providerContext: { + amount: 100, + currency_code: "usd", + payment_session_data: {}, + context: {}, + resource_id: "test", + email: "test@test.com", + billing_address: {}, + customer: {}, + }, + }) + + const payment = await service.authorizePaymentSession(session.id, {}) + + expect(payment).toEqual( + expect.objectContaining({ + id: expect.any(String), + amount: 100, + authorized_amount: 100, + currency_code: "usd", + provider_id: "system", + + refunds: [], + captures: [], + data: {}, cart_id: null, order_id: null, order_edit_id: null, customer_id: null, - data: {}, deleted_at: null, captured_at: null, canceled_at: null, - refunds: [], - captures: [], - amount: 200, - currency_code: "usd", - provider_id: "manual", payment_collection: expect.objectContaining({ - id: paymentCollection.id, - }), - payment_session: expect.objectContaining({ - id: paymentCollection.payment_sessions![0].id, + id: expect.any(String), }), + payment_session: { + id: expect.any(String), + currency_code: "usd", + amount: 100, + provider_id: "system", + data: {}, + status: "authorized", + authorized_at: expect.any(Date), + payment_collection: expect.objectContaining({ + id: expect.any(String), + }), + payment: expect.objectContaining({ + authorized_amount: 100, + cart_id: null, + order_id: null, + order_edit_id: null, + customer_id: null, + data: {}, + deleted_at: null, + captured_at: null, + canceled_at: null, + refunds: [], + captures: [], + amount: 100, + currency_code: "usd", + provider_id: "system", + }), + }, }) ) }) }) + }) + + describe("Payment", () => { + let service: IPaymentModuleService + let shutdownFunc: () => Promise + + afterAll(async () => { + await shutdownFunc() + }) + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + const repositoryManager = await MikroOrmWrapper.forkManager() + + const initModulesConfig = getInitModuleConfig() + const { medusaApp, shutdown } = await initModules(initModulesConfig) + service = medusaApp.modules[Modules.PAYMENT] + + shutdownFunc = shutdown + + await createPaymentCollections(repositoryManager) + await createPaymentSessions(repositoryManager) + await createPayments(repositoryManager) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + await shutdownFunc() + }) describe("update", () => { it("should update a payment successfully", async () => { @@ -417,83 +610,21 @@ describe("Payment Module Service", () => { ) }) - it("should capture payments in bulk successfully", async () => { - const capturedPayments = await service.capturePayment([ - { - amount: 50, // partially captured - payment_id: "pay-id-1", - }, - { - amount: 100, // fully captured - payment_id: "pay-id-2", - }, - ]) - - expect(capturedPayments).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "pay-id-1", - amount: 100, - authorized_amount: 100, - captured_at: null, - captures: [ - expect.objectContaining({ - created_by: null, - amount: 50, - }), - ], - // captured_amount: 50, - }), - expect.objectContaining({ - id: "pay-id-2", - amount: 100, - authorized_amount: 100, - // captured_at: expect.any(Date), - captures: [ - expect.objectContaining({ - created_by: null, - amount: 100, - }), - ], - // captured_amount: 100, - }), - ]) - ) - }) - // TODO: uncomment when totals are implemented - // it("should fail to capture payments in bulk if one of the captures fail", async () => { - // const error = await service - // .capturePayment([ - // { - // amount: 50, + + // it("should fail to capture amount greater than authorized", async () => { + // const error = await service + // .capturePayment({ + // amount: 200, // payment_id: "pay-id-1", - // }, - // { - // amount: 200, // exceeds authorized amount - // payment_id: "pay-id-2", - // }, - // ]) - // .catch((e) => e) + // }) + // .catch((e) => e) // - // expect(error.message).toEqual( - // "Total captured amount for payment: pay-id-2 exceeds authorized amount." - // ) - // }) - - // it("should fail to capture amount greater than authorized", async () => { - // const error = await service - // .capturePayment({ - // amount: 200, - // payment_id: "pay-id-1", - // }) - // .catch((e) => e) + // expect(error.message).toEqual( + // "Total captured amount for payment: pay-id-1 exceeds authorised amount." + // ) + // }) // - // expect(error.message).toEqual( - // "Total captured amount for payment: pay-id-1 exceeds authorized amount." - // ) - // }) - // it("should fail to capture already captured payment", async () => { // await service.capturePayment({ // amount: 100, @@ -507,60 +638,52 @@ describe("Payment Module Service", () => { // }) // .catch((e) => e) // - // expect(error.message).toEqual("The payment is already fully captured.") + // expect(error.message).toEqual( + // "The payment: pay-id-1 is already fully captured." + // ) + // }) + // + // it("should fail to capture a canceled payment", async () => { + // await service.cancelPayment("pay-id-1") + // + // const error = await service + // .capturePayment({ + // amount: 100, + // payment_id: "pay-id-1", + // }) + // .catch((e) => e) + // + // expect(error.message).toEqual( + // "The payment: pay-id-1 has been canceled." + // ) // }) }) describe("refund", () => { - it("should refund a payments in bulk successfully", async () => { - await service.capturePayment({ - amount: 100, - payment_id: "pay-id-1", - }) - + it("should refund a payments successfully", async () => { await service.capturePayment({ amount: 100, payment_id: "pay-id-2", }) - const refundedPayment = await service.refundPayment([ - { - amount: 100, - payment_id: "pay-id-1", - }, - { - amount: 100, - payment_id: "pay-id-2", - }, - ]) + const refundedPayment = await service.refundPayment({ + amount: 100, + payment_id: "pay-id-2", + }) expect(refundedPayment).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "pay-id-1", - amount: 100, - refunds: [ - expect.objectContaining({ - created_by: null, - amount: 100, - }), - ], - // captured_amount: 100, - // refunded_amount: 100, - }), - expect.objectContaining({ - id: "pay-id-2", - amount: 100, - refunds: [ - expect.objectContaining({ - created_by: null, - amount: 100, - }), - ], - // captured_amount: 100, - // refunded_amount: 100, - }), - ]) + expect.objectContaining({ + id: "pay-id-2", + amount: 100, + refunds: [ + expect.objectContaining({ + created_by: null, + amount: 100, + }), + ], + // captured_amount: 100, + // refunded_amount: 100, + }) ) }) @@ -582,5 +705,31 @@ describe("Payment Module Service", () => { // ) // }) }) + + describe("cancel", () => { + it("should cancel a payment", async () => { + const payment = await service.cancelPayment("pay-id-2") + + expect(payment).toEqual( + expect.objectContaining({ + id: "pay-id-2", + canceled_at: expect.any(Date), + }) + ) + }) + + // TODO: revisit when totals are implemented + // it("should throw if trying to cancel a captured payment", async () => { + // await service.capturePayment({ payment_id: "pay-id-2", amount: 100 }) + // + // const error = await service + // .cancelPayment("pay-id-2") + // .catch((e) => e.message) + // + // expect(error).toEqual( + // "Cannot cancel a payment: pay-id-2 that has been captured." + // ) + // }) + }) }) }) diff --git a/packages/payment/src/loaders/defaults.ts b/packages/payment/src/loaders/defaults.ts new file mode 100644 index 0000000000..e59529e65a --- /dev/null +++ b/packages/payment/src/loaders/defaults.ts @@ -0,0 +1,10 @@ +import { IPaymentModuleService, LoaderOptions } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export default async ({ container }: LoaderOptions): Promise => { + const paymentModuleService: IPaymentModuleService = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + await paymentModuleService.createProvidersOnLoad() +} diff --git a/packages/payment/src/loaders/index.ts b/packages/payment/src/loaders/index.ts index 0446db6943..9a4ba9d183 100644 --- a/packages/payment/src/loaders/index.ts +++ b/packages/payment/src/loaders/index.ts @@ -1,4 +1,4 @@ export * from "./connection" export * from "./container" export * from "./providers" - +export * from "./defaults" diff --git a/packages/payment/src/loaders/providers.ts b/packages/payment/src/loaders/providers.ts index f2d5004697..1366db249c 100644 --- a/packages/payment/src/loaders/providers.ts +++ b/packages/payment/src/loaders/providers.ts @@ -3,9 +3,11 @@ import { moduleProviderLoader } from "@medusajs/modules-sdk" import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types" import { Lifetime, asFunction } from "awilix" +import * as providers from "../providers" + const registrationFn = async (klass, container, pluginOptions) => { container.register({ - [`payment_provider_${klass.prototype}`]: asFunction( + [`pp_${klass.identifier}`]: asFunction( (cradle) => new klass(cradle, pluginOptions), { lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, @@ -30,12 +32,14 @@ export default async ({ | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions ) & { providers: ModuleProvider[] } >): Promise => { - const pluginProviders = - options?.providers?.filter((provider) => provider.resolve) || [] + // Local providers + for (const provider of Object.values(providers)) { + await registrationFn(provider, container, {}) + } await moduleProviderLoader({ container, - providers: pluginProviders, + providers: options?.providers || [], registerServiceFn: registrationFn, }) } diff --git a/packages/payment/src/models/payment-collection.ts b/packages/payment/src/models/payment-collection.ts index 3cc690fea1..a29621fa10 100644 --- a/packages/payment/src/models/payment-collection.ts +++ b/packages/payment/src/models/payment-collection.ts @@ -43,19 +43,21 @@ export default class PaymentCollection { }) amount: number - @Property({ - columnType: "numeric", - nullable: true, - serializer: optionalNumericSerializer, - }) - authorized_amount: number | null = null + // TODO: make this computed properties - @Property({ - columnType: "numeric", - nullable: true, - serializer: optionalNumericSerializer, - }) - refunded_amount: number | null = null + // @Property({ + // columnType: "numeric", + // nullable: true, + // serializer: optionalNumericSerializer, + // }) + // authorized_amount: number | null = null + // + // @Property({ + // columnType: "numeric", + // nullable: true, + // serializer: optionalNumericSerializer, + // }) + // refunded_amount: number | null = null @Property({ columnType: "text", index: "IDX_payment_collection_region_id" }) region_id: string diff --git a/packages/payment/src/models/payment-session.ts b/packages/payment/src/models/payment-session.ts index 1e11878ae1..c0f8230185 100644 --- a/packages/payment/src/models/payment-session.ts +++ b/packages/payment/src/models/payment-session.ts @@ -16,7 +16,7 @@ import Payment from "./payment" @Entity({ tableName: "payment_session" }) export default class PaymentSession { - [OptionalProps]?: "status" + [OptionalProps]?: "status" | "data" @PrimaryKey({ columnType: "text" }) id: string @@ -33,8 +33,8 @@ export default class PaymentSession { @Property({ columnType: "text" }) provider_id: string - @Property({ columnType: "jsonb", nullable: true }) - data: Record | null = null + @Property({ columnType: "jsonb" }) + data: Record = {} @Enum({ items: () => PaymentSessionStatus, diff --git a/packages/payment/src/module-definition.ts b/packages/payment/src/module-definition.ts index daae25a16c..456ae8d80f 100644 --- a/packages/payment/src/module-definition.ts +++ b/packages/payment/src/module-definition.ts @@ -5,6 +5,7 @@ import { PaymentModuleService } from "@services" import loadConnection from "./loaders/connection" import loadContainer from "./loaders/container" import loadProviders from "./loaders/providers" +import loadDefaults from "./loaders/defaults" import { Modules } from "@medusajs/modules-sdk" import { ModulesSdkUtils } from "@medusajs/utils" @@ -25,7 +26,12 @@ export const revertMigration = ModulesSdkUtils.buildRevertMigrationScript( ) const service = PaymentModuleService -const loaders = [loadContainer, loadConnection, loadProviders] as any +const loaders = [ + loadContainer, + loadConnection, + loadProviders, + loadDefaults, +] as any export const moduleDefinition: ModuleExports = { service, diff --git a/packages/payment/src/providers/index.ts b/packages/payment/src/providers/index.ts new file mode 100644 index 0000000000..79d8db4231 --- /dev/null +++ b/packages/payment/src/providers/index.ts @@ -0,0 +1 @@ +export { default as SystemPaymentProvider } from "./system" diff --git a/packages/payment/src/providers/system.ts b/packages/payment/src/providers/system.ts new file mode 100644 index 0000000000..7694b6d488 --- /dev/null +++ b/packages/payment/src/providers/system.ts @@ -0,0 +1,71 @@ +import { + PaymentProviderContext, + PaymentProviderError, + PaymentProviderSessionResponse, + PaymentSessionStatus, +} from "@medusajs/types" +import { AbstractPaymentProvider } from "@medusajs/utils" + +export class SystemProviderService extends AbstractPaymentProvider { + static identifier = "system" + + async getStatus(_): Promise { + return "authorized" + } + + async getPaymentData(_): Promise> { + return {} + } + + async initiatePayment( + context: PaymentProviderContext + ): Promise { + return { data: {} } + } + + async getPaymentStatus( + paymentSessionData: Record + ): Promise { + throw new Error("Method not implemented.") + } + + async retrievePayment( + paymentSessionData: Record + ): Promise | PaymentProviderError> { + return {} + } + + async authorizePayment(_): Promise< + | PaymentProviderError + | { + status: PaymentSessionStatus + data: PaymentProviderSessionResponse["data"] + } + > { + return { data: {}, status: PaymentSessionStatus.AUTHORIZED } + } + + async updatePayment( + _ + ): Promise { + return { data: {} } as PaymentProviderSessionResponse + } + + async deletePayment(_): Promise> { + return {} + } + + async capturePayment(_): Promise> { + return {} + } + + async refundPayment(_): Promise> { + return {} + } + + async cancelPayment(_): Promise> { + return {} + } +} + +export default SystemProviderService diff --git a/packages/payment/src/services/index.ts b/packages/payment/src/services/index.ts index a4f5ea37ae..b331c37d29 100644 --- a/packages/payment/src/services/index.ts +++ b/packages/payment/src/services/index.ts @@ -1 +1,2 @@ export { default as PaymentModuleService } from "./payment-module" +export { default as PaymentProviderService } from "./payment-provider" diff --git a/packages/payment/src/services/payment-module.ts b/packages/payment/src/services/payment-module.ts index 6f5b2b075a..e6c036dbe3 100644 --- a/packages/payment/src/services/payment-module.ts +++ b/packages/payment/src/services/payment-module.ts @@ -4,6 +4,7 @@ import { CreateCaptureDTO, CreatePaymentCollectionDTO, CreatePaymentDTO, + CreatePaymentProviderDTO, CreatePaymentSessionDTO, CreateRefundDTO, DAL, @@ -14,20 +15,18 @@ import { PaymentCollectionDTO, PaymentDTO, PaymentSessionDTO, + PaymentSessionStatus, RefundDTO, - SetPaymentSessionsDTO, UpdatePaymentCollectionDTO, UpdatePaymentDTO, + UpdatePaymentSessionDTO, } from "@medusajs/types" import { InjectTransactionManager, MedusaContext, - ModulesSdkUtils, MedusaError, - InjectManager, + ModulesSdkUtils, } from "@medusajs/utils" - -import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import { Capture, Payment, @@ -36,6 +35,9 @@ import { Refund, } from "@models" +import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" +import PaymentProviderService from "./payment-provider" + type InjectedDependencies = { baseRepository: DAL.RepositoryService paymentService: ModulesSdkTypes.InternalModuleService @@ -43,9 +45,10 @@ type InjectedDependencies = { refundService: ModulesSdkTypes.InternalModuleService paymentSessionService: ModulesSdkTypes.InternalModuleService paymentCollectionService: ModulesSdkTypes.InternalModuleService + paymentProviderService: PaymentProviderService } -const generateMethodForModels = [PaymentCollection, PaymentSession] +const generateMethodForModels = [PaymentCollection, Payment] export default class PaymentModuleService< TPaymentCollection extends PaymentCollection = PaymentCollection, @@ -74,6 +77,7 @@ export default class PaymentModuleService< protected refundService_: ModulesSdkTypes.InternalModuleService protected paymentSessionService_: ModulesSdkTypes.InternalModuleService protected paymentCollectionService_: ModulesSdkTypes.InternalModuleService + protected paymentProviderService_: PaymentProviderService constructor( { @@ -82,6 +86,7 @@ export default class PaymentModuleService< captureService, refundService, paymentSessionService, + paymentProviderService, paymentCollectionService, }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration @@ -95,6 +100,7 @@ export default class PaymentModuleService< this.captureService_ = captureService this.paymentService_ = paymentService this.paymentSessionService_ = paymentSessionService + this.paymentProviderService_ = paymentProviderService this.paymentCollectionService_ = paymentCollectionService } @@ -102,18 +108,18 @@ export default class PaymentModuleService< return joinerConfig } - createPaymentCollection( + createPaymentCollections( data: CreatePaymentCollectionDTO, sharedContext?: Context ): Promise - createPaymentCollection( + createPaymentCollections( data: CreatePaymentCollectionDTO[], sharedContext?: Context ): Promise @InjectTransactionManager("baseRepository_") - async createPaymentCollection( + async createPaymentCollections( data: CreatePaymentCollectionDTO | CreatePaymentCollectionDTO[], @MedusaContext() sharedContext?: Context ): Promise { @@ -132,17 +138,17 @@ export default class PaymentModuleService< ) } - updatePaymentCollection( + updatePaymentCollections( data: UpdatePaymentCollectionDTO[], sharedContext?: Context ): Promise - updatePaymentCollection( + updatePaymentCollections( data: UpdatePaymentCollectionDTO, sharedContext?: Context ): Promise @InjectTransactionManager("baseRepository_") - async updatePaymentCollection( + async updatePaymentCollections( data: UpdatePaymentCollectionDTO | UpdatePaymentCollectionDTO[], sharedContext?: Context ): Promise { @@ -160,305 +166,361 @@ export default class PaymentModuleService< ) } - createPayment( - data: CreatePaymentDTO, + completePaymentCollections( + paymentCollectionId: string, sharedContext?: Context - ): Promise - createPayment( - data: CreatePaymentDTO[], + ): Promise + completePaymentCollections( + paymentCollectionId: string[], sharedContext?: Context - ): Promise + ): Promise @InjectTransactionManager("baseRepository_") - async createPayment( - data: CreatePaymentDTO | CreatePaymentDTO[], + async completePaymentCollections( + paymentCollectionId: string | string[], @MedusaContext() sharedContext?: Context - ): Promise { - let input = Array.isArray(data) ? data : [data] + ): Promise { + const input = Array.isArray(paymentCollectionId) + ? paymentCollectionId.map((id) => ({ + id, + completed_at: new Date(), + })) + : [{ id: paymentCollectionId, completed_at: new Date() }] - input = input.map((inputData) => ({ - payment_collection: inputData.payment_collection_id, - payment_session: inputData.payment_session_id, - ...inputData, - })) + // TODO: what checks should be done here? e.g. captured_amount === amount? - const payments = await this.paymentService_.create(input, sharedContext) - - return await this.baseRepository_.serialize( - Array.isArray(data) ? payments : payments[0], - { - populate: true, - } + const updated = await this.paymentCollectionService_.update( + input, + sharedContext ) - } - - updatePayment( - data: UpdatePaymentDTO, - sharedContext?: Context | undefined - ): Promise - updatePayment( - data: UpdatePaymentDTO[], - sharedContext?: Context | undefined - ): Promise - - @InjectTransactionManager("baseRepository_") - async updatePayment( - data: UpdatePaymentDTO | UpdatePaymentDTO[], - @MedusaContext() sharedContext?: Context - ): Promise { - const input = Array.isArray(data) ? data : [data] - const result = await this.paymentService_.update(input, sharedContext) - - return await this.baseRepository_.serialize( - Array.isArray(data) ? result : result[0], - { - populate: true, - } - ) - } - - capturePayment( - data: CreateCaptureDTO, - sharedContext?: Context - ): Promise - capturePayment( - data: CreateCaptureDTO[], - sharedContext?: Context - ): Promise - - @InjectManager("baseRepository_") - async capturePayment( - data: CreateCaptureDTO | CreateCaptureDTO[], - @MedusaContext() sharedContext: Context = {} - ): Promise { - const input = Array.isArray(data) ? data : [data] - - const payments = await this.capturePaymentBulk_(input, sharedContext) return await this.baseRepository_.serialize( - Array.isArray(data) ? payments : payments[0], + Array.isArray(paymentCollectionId) ? updated : updated[0], { populate: true } ) } - @InjectTransactionManager("baseRepository_") - protected async capturePaymentBulk_( - data: CreateCaptureDTO[], - @MedusaContext() sharedContext?: Context - ): Promise { - let payments = await this.paymentService_.list( - { id: data.map((d) => d.payment_id) }, - {}, - sharedContext - ) - const inputMap = new Map(data.map((d) => [d.payment_id, d])) - - for (const payment of payments) { - const input = inputMap.get(payment.id)! - - if (payment.captured_at) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "The payment is already fully captured." - ) - } - - // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged - // if (payment.captured_amount + input.amount > payment.authorized_amount) { - // throw new MedusaError( - // MedusaError.Types.INVALID_DATA, - // `Total captured amount for payment: ${payment.id} exceeds authorized amount.` - // ) - // } - } - - await this.captureService_.create( - data.map((d) => ({ - payment: d.payment_id, - amount: d.amount, - captured_by: d.captured_by, - })), - sharedContext - ) - - let fullyCapturedPaymentsId: string[] = [] - for (const payment of payments) { - const input = inputMap.get(payment.id)! - - // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged - // if (payment.captured_amount + input.amount === payment.amount) { - // fullyCapturedPaymentsId.push(payment.id) - // } - } - - if (fullyCapturedPaymentsId.length) { - await this.paymentService_.update( - fullyCapturedPaymentsId.map((id) => ({ id, captured_at: new Date() })), - sharedContext - ) - } - - // TODO: set PaymentCollection status if fully captured - - return await this.paymentService_.list( - { id: data.map((d) => d.payment_id) }, - { - relations: ["captures"], - }, - sharedContext - ) - } - - refundPayment( - data: CreateRefundDTO, - sharedContext?: Context - ): Promise - refundPayment( - data: CreateRefundDTO[], - sharedContext?: Context - ): Promise - - @InjectManager("baseRepository_") - async refundPayment( - data: CreateRefundDTO | CreateRefundDTO[], - @MedusaContext() sharedContext?: Context - ): Promise { - const input = Array.isArray(data) ? data : [data] - - const payments = await this.refundPaymentBulk_(input, sharedContext) - - return await this.baseRepository_.serialize( - Array.isArray(data) ? payments : payments[0], - { populate: true } - ) - } - - @InjectTransactionManager("baseRepository_") - async refundPaymentBulk_( - data: CreateRefundDTO[], - @MedusaContext() sharedContext?: Context - ): Promise { - const payments = await this.paymentService_.list( - { id: data.map(({ payment_id }) => payment_id) }, - {}, - sharedContext - ) - - const inputMap = new Map(data.map((d) => [d.payment_id, d])) - - // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged - // for (const payment of payments) { - // const input = inputMap.get(payment.id)! - // if (payment.captured_amount < input.amount) { - // throw new MedusaError( - // MedusaError.Types.INVALID_DATA, - // `Refund amount for payment: ${payment.id} cannot be greater than the amount captured on the payment.` - // ) - // } - // } - - await this.refundService_.create( - data.map((d) => ({ - payment: d.payment_id, - amount: d.amount, - captured_by: d.created_by, - })), - sharedContext - ) - - return await this.paymentService_.list( - { id: data.map(({ payment_id }) => payment_id) }, - { - relations: ["refunds"], - }, - sharedContext - ) - } - - createPaymentSession( - paymentCollectionId: string, - data: CreatePaymentSessionDTO, - sharedContext?: Context | undefined - ): Promise - createPaymentSession( - paymentCollectionId: string, - data: CreatePaymentSessionDTO[], - sharedContext?: Context | undefined - ): Promise - @InjectTransactionManager("baseRepository_") async createPaymentSession( paymentCollectionId: string, - data: CreatePaymentSessionDTO | CreatePaymentSessionDTO[], + data: CreatePaymentSessionDTO, @MedusaContext() sharedContext?: Context - ): Promise { - let input = Array.isArray(data) ? data : [data] + ): Promise { + const sessionData = await this.paymentProviderService_.createSession( + data.provider_id, + data.providerContext + ) - input = input.map((inputData) => ({ - payment_collection: paymentCollectionId, - ...inputData, - })) - - await this.paymentSessionService_.create(input, sharedContext) - - return await this.retrievePaymentCollection( - paymentCollectionId, + const created = await this.paymentSessionService_.create( { - relations: ["payment_sessions"], + provider_id: data.provider_id, + amount: data.providerContext.amount, + currency_code: data.providerContext.currency_code, + payment_collection: paymentCollectionId, + data: sessionData, }, sharedContext ) + + return await this.baseRepository_.serialize(created, { populate: true }) + } + + @InjectTransactionManager("baseRepository_") + async updatePaymentSession( + data: UpdatePaymentSessionDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + const session = await this.paymentSessionService_.retrieve( + data.id, + { select: ["id", "data", "provider_id"] }, + 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, + }, + sharedContext + ) + + return await this.baseRepository_.serialize(updated[0], { populate: true }) + } + + @InjectTransactionManager("baseRepository_") + async deletePaymentSession( + id: string, + @MedusaContext() sharedContext?: Context + ): Promise { + const session = await this.paymentSessionService_.retrieve( + id, + { select: ["id", "data", "provider_id"] }, + sharedContext + ) + + await this.paymentProviderService_.deleteSession({ + provider_id: session.provider_id, + data: session.data, + }) + + await this.paymentSessionService_.delete(id, sharedContext) + } + + @InjectTransactionManager("baseRepository_") + async authorizePaymentSession( + id: string, + context: Record, + @MedusaContext() sharedContext?: Context + ): Promise { + const session = await this.paymentSessionService_.retrieve( + id, + { + select: ["id", "data", "provider_id", "amount", "currency_code"], + relations: ["payment_collection"], + }, + sharedContext + ) + + if (session.authorized_at) { + const payment = await this.paymentService_.retrieve( + { session_id: session.id }, + {}, + sharedContext + ) + return await this.baseRepository_.serialize(payment, { populate: true }) + } + + const { data, status } = + await this.paymentProviderService_.authorizePayment( + { + provider_id: session.provider_id, + data: session.data, + }, + context + ) + + await this.paymentSessionService_.update( + { + id: session.id, + data, + status, + authorized_at: + status === PaymentSessionStatus.AUTHORIZED ? new Date() : null, + }, + sharedContext + ) + + if (status !== PaymentSessionStatus.AUTHORIZED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Session: ${session.id} is not authorized with the provider.` + ) + } + + // TODO: update status on payment collection if authorized_amount === amount - depends on the BigNumber PR + + const payment = await this.paymentService_.create( + { + amount: session.amount, + currency_code: session.currency_code, + authorized_amount: session.amount, + payment_session: session.id, + payment_collection: session.payment_collection!.id, + provider_id: session.provider_id, + // customer_id: context.customer.id, + data, + }, + sharedContext + ) + + return await this.retrievePayment(payment.id, {}, sharedContext) + } + + @InjectTransactionManager("baseRepository_") + async updatePayment( + data: UpdatePaymentDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + // NOTE: currently there is no update with the provider but maybe data could be updated + const result = await this.paymentService_.update(data, sharedContext) + + return await this.baseRepository_.serialize(result[0], { + populate: true, + }) + } + + @InjectTransactionManager("baseRepository_") + async capturePayment( + data: CreateCaptureDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const payment = await this.paymentService_.retrieve( + data.payment_id, + { select: ["id", "data", "provider_id"] }, + sharedContext + ) + + if (payment.canceled_at) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The payment: ${payment.id} has been canceled.` + ) + } + + if (payment.captured_at) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The payment: ${payment.id} is already fully captured.` + ) + } + + // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged + // if (payment.captured_amount + input.amount > payment.authorized_amount) { + // throw new MedusaError( + // MedusaError.Types.INVALID_DATA, + // `Total captured amount for payment: ${payment.id} exceeds authorized amount.` + // ) + // } + + const paymentData = await this.paymentProviderService_.capturePayment({ + data: payment.data!, + provider_id: payment.provider_id, + }) + + await this.captureService_.create( + { + payment: data.payment_id, + amount: data.amount, + captured_by: data.captured_by, + }, + sharedContext + ) + + await this.paymentService_.update( + { id: payment.id, data: paymentData }, + sharedContext + ) + + // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged + // if (payment.captured_amount + data.amount === payment.amount) { + // await this.paymentService_.update( + // { id: payment.id, captured_at: new Date() }, + // sharedContext + // ) + // } + + return await this.retrievePayment( + payment.id, + { relations: ["captures"] }, + sharedContext + ) } - /** - * TODO - */ + @InjectTransactionManager("baseRepository_") + async refundPayment( + data: CreateRefundDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + const payment = await this.paymentService_.retrieve( + data.payment_id, + { select: ["id", "data", "provider_id"] }, + sharedContext + ) - authorizePaymentCollection( - paymentCollectionId: string, - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") - } - completePaymentCollection( - paymentCollectionId: string, - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") + // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged + // if (payment.captured_amount < input.amount) { + // throw new MedusaError( + // MedusaError.Types.INVALID_DATA, + // `Refund amount for payment: ${payment.id} cannot be greater than the amount captured on the payment.` + // ) + // } + + const paymentData = await this.paymentProviderService_.refundPayment( + { + data: payment.data!, + provider_id: payment.provider_id, + }, + data.amount + ) + + await this.refundService_.create( + { + payment: data.payment_id, + amount: data.amount, + created_by: data.created_by, + }, + sharedContext + ) + + await this.paymentService_.update({ id: payment.id, data: paymentData }) + + return await this.retrievePayment( + payment.id, + { relations: ["refunds"] }, + sharedContext + ) } - cancelPayment(paymentId: string, sharedContext?: Context): Promise - cancelPayment( - paymentId: string[], - sharedContext?: Context - ): Promise + @InjectTransactionManager("baseRepository_") + async cancelPayment( + paymentId: string, + @MedusaContext() sharedContext?: Context + ): Promise { + const payment = await this.paymentService_.retrieve( + paymentId, + { select: ["id", "data", "provider_id"] }, + sharedContext + ) - cancelPayment( - paymentId: string | string[], - sharedContext?: Context - ): Promise { - throw new Error("Method not implemented.") + // TODO: revisit when totals are implemented + // if (payment.captured_amount !== 0) { + // throw new MedusaError( + // MedusaError.Types.INVALID_DATA, + // `Cannot cancel a payment: ${payment.id} that has been captured.` + // ) + // } + + await this.paymentProviderService_.cancelPayment({ + data: payment.data!, + provider_id: payment.provider_id, + }) + + await this.paymentService_.update( + { id: paymentId, canceled_at: new Date() }, + sharedContext + ) + + return await this.retrievePayment(payment.id, {}, sharedContext) } - authorizePaymentSessions( - paymentCollectionId: string, - sessionIds: string[], - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") - } - completePaymentSessions( - paymentCollectionId: string, - sessionIds: string[], - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") - } - setPaymentSessions( - paymentCollectionId: string, - data: SetPaymentSessionsDTO[], - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") + async createProvidersOnLoad() { + const providersToLoad = this.__container__["payment_providers"] + + const providers = await this.paymentProviderService_.list({ + // @ts-ignore TODO + id: providersToLoad.map((p) => p.getIdentifier()), + }) + + const loadedProvidersMap = new Map(providers.map((p) => [p.id, p])) + + const providersToCreate: CreatePaymentProviderDTO[] = [] + for (const provider of providersToLoad) { + if (loadedProvidersMap.has(provider.getIdentifier())) { + continue + } + + providersToCreate.push({ + id: provider.getIdentifier(), + }) + } + + await this.paymentProviderService_.create(providersToCreate) } } diff --git a/packages/payment/src/services/payment-provider.ts b/packages/payment/src/services/payment-provider.ts new file mode 100644 index 0000000000..261ce54b10 --- /dev/null +++ b/packages/payment/src/services/payment-provider.ts @@ -0,0 +1,183 @@ +import { EOL } from "os" +import { isDefined, MedusaError } from "medusa-core-utils" +import { + Context, + CreatePaymentProviderDTO, + DAL, + InternalModuleDeclaration, + IPaymentProvider, + PaymentProviderAuthorizeResponse, + PaymentProviderContext, + PaymentProviderDataInput, + PaymentProviderError, + PaymentProviderSessionResponse, + PaymentSessionStatus, +} from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + isPaymentProviderError, + MedusaContext, +} from "@medusajs/utils" + +import { PaymentProvider } from "@models" + +type InjectedDependencies = { + paymentProviderRepository: DAL.RepositoryService + [key: `pp_${string}`]: IPaymentProvider +} + +export default class PaymentProviderService { + protected readonly container_: InjectedDependencies + protected readonly paymentProviderRepository_: DAL.RepositoryService + + constructor( + container: InjectedDependencies, + + protected readonly moduleDeclaration: InternalModuleDeclaration + ) { + this.container_ = container + this.paymentProviderRepository_ = container.paymentProviderRepository + } + + @InjectTransactionManager("paymentProviderRepository_") + async create( + data: CreatePaymentProviderDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + return await this.paymentProviderRepository_.create(data, sharedContext) + } + + @InjectManager("paymentProviderRepository_") + async list( + @MedusaContext() sharedContext?: Context + ): Promise { + return await this.paymentProviderRepository_.find(undefined, sharedContext) + } + + retrieveProvider(providerId: string): IPaymentProvider { + try { + return this.container_[`pp_${providerId}`] as IPaymentProvider + } catch (e) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a payment provider with id: ${providerId}` + ) + } + } + + async createSession( + providerId: string, + sessionInput: PaymentProviderContext + ): 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)) { + this.throwPaymentProviderError(paymentResponse) + } + + return (paymentResponse as PaymentProviderSessionResponse).data + } + + async updateSession( + providerId: string, + sessionInput: PaymentProviderContext + ): Promise | undefined> { + const provider = this.retrieveProvider(providerId) + + const paymentResponse = await provider.updatePayment(sessionInput) + + if (isPaymentProviderError(paymentResponse)) { + this.throwPaymentProviderError(paymentResponse) + } + + return (paymentResponse as PaymentProviderSessionResponse)?.data + } + + async deleteSession(input: PaymentProviderDataInput): Promise { + const provider = this.retrieveProvider(input.provider_id) + + const error = await provider.deletePayment(input.data) + if (isPaymentProviderError(error)) { + this.throwPaymentProviderError(error) + } + } + + async authorizePayment( + input: PaymentProviderDataInput, + context: Record + ): Promise<{ data: Record; status: PaymentSessionStatus }> { + const provider = this.retrieveProvider(input.provider_id) + + const res = await provider.authorizePayment(input.data, context) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) + } + + const { data, status } = res as PaymentProviderAuthorizeResponse + return { data, status } + } + + async getStatus( + input: PaymentProviderDataInput + ): Promise { + const provider = this.retrieveProvider(input.provider_id) + return await provider.getPaymentStatus(input.data) + } + + async capturePayment( + input: PaymentProviderDataInput + ): Promise> { + const provider = this.retrieveProvider(input.provider_id) + + const res = await provider.capturePayment(input.data) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) + } + + return res as Record + } + + async cancelPayment(input: PaymentProviderDataInput): Promise { + const provider = this.retrieveProvider(input.provider_id) + + const error = await provider.cancelPayment(input.data) + if (isPaymentProviderError(error)) { + this.throwPaymentProviderError(error) + } + } + + async refundPayment( + input: PaymentProviderDataInput, + amount: number + ): Promise> { + const provider = this.retrieveProvider(input.provider_id) + + const res = await provider.refundPayment(input.data, amount) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) + } + + return res as Record + } + + private throwPaymentProviderError(errObj: PaymentProviderError) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `${errObj.error}${errObj.detail ? `:${EOL}${errObj.detail}` : ""}`, + errObj.code + ) + } +} diff --git a/packages/types/src/payment/common.ts b/packages/types/src/payment/common.ts index 021138cb46..bed4f642ff 100644 --- a/packages/types/src/payment/common.ts +++ b/packages/types/src/payment/common.ts @@ -31,6 +31,34 @@ export enum PaymentCollectionStatus { CANCELED = "canceled", } +/** + * @enum + * + * The status of a payment session. + */ +export enum PaymentSessionStatus { + /** + * The payment is authorized. + */ + AUTHORIZED = "authorized", + /** + * The payment is pending. + */ + PENDING = "pending", + /** + * The payment requires an action. + */ + REQUIRES_MORE = "requires_more", + /** + * An error occurred while processing the payment. + */ + ERROR = "error", + /** + * The payment is canceled. + */ + CANCELED = "canceled", +} + export interface PaymentCollectionDTO { /** * The ID of the Payment Collection @@ -255,6 +283,48 @@ export interface PaymentSessionDTO { * The ID of the Payment Session */ id: string + + /** + * The amount + */ + amount: number + + /** + * Payment session currency + */ + currency_code: string + + /** + * The ID of payment provider + */ + provider_id: string + + /** + * Payment provider data + */ + data: Record + + /** + * The status of the payment session + */ + status: PaymentSessionStatus + + /** + * When the session was authorized + */ + authorized_at?: Date + + /** + * The payment collection the session is associated with + * @expandable + */ + payment_collection?: PaymentCollectionDTO + + /** + * The payment created from the session + * @expandable + */ + payment?: PaymentDTO } export interface PaymentProviderDTO { diff --git a/packages/types/src/payment/index.ts b/packages/types/src/payment/index.ts index a83c8a61ef..3344805068 100644 --- a/packages/types/src/payment/index.ts +++ b/packages/types/src/payment/index.ts @@ -1,4 +1,4 @@ export * from "./common" export * from "./mutations" +export * from "./provider" export * from "./service" - diff --git a/packages/types/src/payment/mutations.ts b/packages/types/src/payment/mutations.ts index 92d6ed6487..5c228d521e 100644 --- a/packages/types/src/payment/mutations.ts +++ b/packages/types/src/payment/mutations.ts @@ -1,4 +1,5 @@ import { PaymentCollectionStatus } from "./common" +import { PaymentProviderContext } from "./provider" /** * Payment Collection @@ -26,6 +27,7 @@ export interface UpdatePaymentCollectionDTO export interface CreatePaymentDTO { amount: number + currency_code: string provider_id: string data: Record @@ -46,8 +48,6 @@ export interface UpdatePaymentDTO { order_id?: string order_edit_id?: string customer_id?: string - - data?: Record } export interface CreateCaptureDTO { @@ -69,17 +69,19 @@ export interface CreateRefundDTO { */ export interface CreatePaymentSessionDTO { - amount: number - currency_code: string provider_id: string - - cart_id?: string - resource_id?: string - customer_id?: string + providerContext: PaymentProviderContext } -export interface SetPaymentSessionsDTO { - provider_id: string - amount: number - session_id?: string +export interface UpdatePaymentSessionDTO { + id: string + providerContext: PaymentProviderContext +} + +/** + * Payment Provider + */ +export interface CreatePaymentProviderDTO { + id: string + is_enabled?: boolean } diff --git a/packages/types/src/payment/provider.ts b/packages/types/src/payment/provider.ts new file mode 100644 index 0000000000..0102d5d791 --- /dev/null +++ b/packages/types/src/payment/provider.ts @@ -0,0 +1,212 @@ +import { PaymentSessionStatus } from "./common" + +/** + * @interface + * + * A payment's context. + */ +export type PaymentProviderContext = { + /** + * The payment's billing address. + */ + billing_address?: Record | null // TODO: revisit types + /** + * 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. For example, the cart's ID. + */ + resource_id: string + /** + * The customer associated with this payment. + */ + customer?: Record // TODO: type + /** + * The context. + */ + context: 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 +} + +/** + * @interface + * + * The response of operations on a payment. + */ +export type PaymentProviderSessionResponse = { + /** + * The data to be stored in the `data` field of the Payment Session to be created. + * The `data` field is useful to hold any data required by the third-party provider to process the payment or retrieve its details at a later point. + */ + data: Record +} + +export type PaymentProviderAuthorizeResponse = { + /** + * The status of the payment, which will be stored in the payment session's `status` field. + */ + status: PaymentSessionStatus + /** + * The `data` to be stored in the payment session's `data` field. + */ + data: PaymentProviderSessionResponse["data"] +} + +export type PaymentProviderDataInput = { + provider_id: string + data: Record +} + +/** + * An object that is returned in case of an error. + */ +export interface PaymentProviderError { + /** + * The error message + */ + error: string + /** + * The error code. + */ + code?: string + /** + * Any additional helpful details. + */ + detail?: any +} + +export interface IPaymentProvider { + /** + * @ignore + * + * Return a unique identifier to retrieve the payment plugin provider + */ + getIdentifier(): string + + /** + * 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. + * @returns {Promise} Either the payment's data or an error object. + */ + initiatePayment( + context: PaymentProviderContext + ): Promise + + /** + * This method is used to update the payment session. + * + * @param {PaymentProviderContext} context - The context of the payment. + * @returns {Promise} Either the payment's data or an error object. + */ + updatePayment( + context: PaymentProviderContext + ): Promise + + /** + * This method is used to perform any actions necessary before a Payment Session is deleted. The Payment Session is deleted in one of the following cases: + * + * @param {Record} paymentSessionData - The `data` field of the Payment Session. + * @returns Either an error object or an empty object. + */ + deletePayment( + paymentSessionData: Record + ): Promise + + /** + * This method is used to authorize payment using the Payment Session. + * You can interact with a third-party provider and perform any actions necessary to authorize the payment. + * + * The payment authorization might require additional action from the customer before it is declared authorized. Once that additional action is performed, + * the `authorizePayment` method will be called again to validate that the payment is now fully authorized. So, make sure to implement it for this case as well, if necessary. + * + * :::note + * + * The payment authorization status is determined using the {@link getPaymentStatus} method. If the status is `requires_more`, then it means additional actions are required + * from the customer. + * + * ::: + * + * @param {Record} paymentSessionData - The `data` field of the payment session. + * @param {Record} context - The context of the authorization. + * @returns The authorization details or an error object. + */ + authorizePayment( + paymentSessionData: Record, + context: Record + ): Promise + + /** + * This method is used to capture the payment amount. This is typically triggered manually by the store operator from the admin. + * + * You can utilize this method to interact with the third-party provider and perform any actions necessary to capture the payment. + * + * @param {Record} paymentSessionData - The `data` field of the Payment for its first parameter. + * @returns Either an error object or a value that's stored in the `data` field of the Payment. + */ + capturePayment( + paymentSessionData: Record + ): Promise + + /** + * This method is used to refund a payment. This is typically triggered manually by the store operator from the admin. The refund amount might be the total amount or part of it. + * + * You can utilize this method to interact with the third-party provider and perform any actions necessary to refund the payment. + * + * @param {Record} paymentSessionData - The `data` field of a Payment. + * @param {number} refundAmount - the amount to refund. + * @returns Either an error object or a value that's stored in the `data` field of the Payment. + */ + refundPayment( + paymentSessionData: Record, + refundAmount: number + ): Promise + + /** + * This method is used to provide a uniform way of retrieving the payment information from the third-party provider. + * For example, in Stripe’s Payment Provider this method is used to retrieve the payment intent details from Stripe. + * + * @param {Record} paymentSessionData - + * The `data` field of a Payment Session. Make sure to store in the `data` field any necessary data that would allow you to retrieve the payment data from the third-party provider. + * @returns {Promise} The payment's data, typically retrieved from a third-party provider. + */ + retrievePayment( + paymentSessionData: Record + ): Promise + + /** + * This method is used to cancel a payment. This method is typically triggered by one of the following situations: + * + * You can utilize this method to interact with the third-party provider and perform any actions necessary to cancel the payment. + * + * @param {Record} paymentSessionData - The `data` field of the Payment. + * @returns Either an error object or a value that's stored in the `data` field of the Payment. + */ + cancelPayment( + paymentSessionData: Record + ): Promise + + /** + * This method is used to get the status of a Payment or a Payment Session. + * + * @param {Record} paymentSessionData - + * The `data` field of a Payment as a parameter. You can use this data to interact with the third-party provider to check the status of the payment if necessary. + * @returns {Promise} The status of the Payment or Payment Session. + */ + getPaymentStatus( + paymentSessionData: Record + ): Promise +} diff --git a/packages/types/src/payment/service.ts b/packages/types/src/payment/service.ts index e038a590bf..cb0c9e46a4 100644 --- a/packages/types/src/payment/service.ts +++ b/packages/types/src/payment/service.ts @@ -6,25 +6,26 @@ import { CreatePaymentDTO, CreatePaymentSessionDTO, CreateRefundDTO, - SetPaymentSessionsDTO, UpdatePaymentCollectionDTO, UpdatePaymentDTO, + UpdatePaymentSessionDTO, } from "./mutations" import { FilterablePaymentCollectionProps, PaymentCollectionDTO, PaymentDTO, + PaymentSessionDTO, } from "./common" import { FindConfig } from "../common" export interface IPaymentModuleService extends IModuleService { /* ********** PAYMENT COLLECTION ********** */ - createPaymentCollection( + createPaymentCollections( data: CreatePaymentCollectionDTO[], sharedContext?: Context ): Promise - createPaymentCollection( + createPaymentCollections( data: CreatePaymentCollectionDTO, sharedContext?: Context ): Promise @@ -47,11 +48,11 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise<[PaymentCollectionDTO[], number]> - updatePaymentCollection( + updatePaymentCollections( data: UpdatePaymentCollectionDTO[], sharedContext?: Context ): Promise - updatePaymentCollection( + updatePaymentCollections( data: UpdatePaymentCollectionDTO, sharedContext?: Context ): Promise @@ -65,59 +66,14 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise - authorizePaymentCollection( + completePaymentCollections( paymentCollectionId: string, sharedContext?: Context ): Promise - - completePaymentCollection( - paymentCollectionId: string, + completePaymentCollections( + paymentCollectionId: string[], sharedContext?: Context - ): Promise - - /* ********** PAYMENT ********** */ - - createPayment( - data: CreatePaymentDTO, - sharedContext?: Context - ): Promise - createPayment( - data: CreatePaymentDTO[], - sharedContext?: Context - ): Promise - - capturePayment( - data: CreateCaptureDTO, - sharedContext?: Context - ): Promise - capturePayment( - data: CreateCaptureDTO[], - sharedContext?: Context - ): Promise - - refundPayment( - data: CreateRefundDTO, - sharedContext?: Context - ): Promise - refundPayment( - data: CreateRefundDTO[], - sharedContext?: Context - ): Promise - - cancelPayment(paymentId: string, sharedContext?: Context): Promise - cancelPayment( - paymentId: string[], - sharedContext?: Context - ): Promise - - updatePayment( - data: UpdatePaymentDTO, - sharedContext?: Context - ): Promise - updatePayment( - data: UpdatePaymentDTO[], - sharedContext?: Context - ): Promise + ): Promise /* ********** PAYMENT SESSION ********** */ @@ -125,28 +81,39 @@ export interface IPaymentModuleService extends IModuleService { paymentCollectionId: string, data: CreatePaymentSessionDTO, sharedContext?: Context - ): Promise - createPaymentSession( - paymentCollectionId: string, - data: CreatePaymentSessionDTO[], - sharedContext?: Context - ): Promise + ): Promise - authorizePaymentSessions( - paymentCollectionId: string, - sessionIds: string[], + updatePaymentSession( + data: UpdatePaymentSessionDTO, sharedContext?: Context - ): Promise + ): Promise - completePaymentSessions( - paymentCollectionId: string, - sessionIds: string[], - sharedContext?: Context - ): Promise + deletePaymentSession(id: string, sharedContext?: Context): Promise - setPaymentSessions( - paymentCollectionId: string, - data: SetPaymentSessionsDTO[], + authorizePaymentSession( + id: string, + context: Record, sharedContext?: Context - ): Promise + ): Promise + + /* ********** PAYMENT ********** */ + + updatePayment( + data: UpdatePaymentDTO, + sharedContext?: Context + ): Promise + + capturePayment( + data: CreateCaptureDTO, + sharedContext?: Context + ): Promise + + refundPayment( + data: CreateRefundDTO, + sharedContext?: Context + ): Promise + + cancelPayment(paymentId: string, sharedContext?: Context): Promise + + createProvidersOnLoad(): Promise } diff --git a/packages/utils/src/payment/abstract-payment-provider.ts b/packages/utils/src/payment/abstract-payment-provider.ts new file mode 100644 index 0000000000..87d5c464e1 --- /dev/null +++ b/packages/utils/src/payment/abstract-payment-provider.ts @@ -0,0 +1,127 @@ +import { + IPaymentProvider, + MedusaContainer, + PaymentProviderContext, + PaymentProviderError, + PaymentProviderSessionResponse, + PaymentSessionStatus, +} from "@medusajs/types" + +export abstract class AbstractPaymentProvider implements IPaymentProvider { + /** + * You can use the `constructor` of your Payment Provider to have access to different services in Medusa through [dependency injection](https://docs.medusajs.com/development/fundamentals/dependency-injection). + * + * You can also use the constructor to initialize your integration with the third-party provider. For example, if you use a client to connect to the third-party provider’s APIs, + * you can initialize it in the constructor and use it in other methods in the service. + * + * Additionally, if you’re creating your Payment Provider as an external plugin to be installed on any Medusa backend and you want to access the options added for the plugin, + * you can access it in the constructor. The options are passed as a second parameter. + * + * @param {MedusaContainer} container - An instance of `MedusaContainer` that allows you to access other resources, such as services, in your Medusa backend through [dependency injection](https://docs.medusajs.com/development/fundamentals/dependency-injection) + * @param {Record} config - If this fulfillment provider is created in a plugin, the plugin's options are passed in this parameter. + * + * @example + * ```ts + * class MyPaymentService extends AbstractPaymentProvider { + * // ... + * constructor(container, options) { + * super(container) + * // you can access options here + * + * // you can also initialize a client that + * // communicates with a third-party service. + * this.client = new Client(options) + * } + * // ... + * } + * ``` + */ + protected constructor( + protected readonly container: MedusaContainer, + protected readonly config?: Record // eslint-disable-next-line @typescript-eslint/no-empty-function + ) {} + + static _isPaymentProvider = true + + static isPaymentProvider(object): boolean { + return object?.constructor?._isPaymentProvider + } + + /** + * The `PaymentProvider` entity has 2 properties: `id` and `is_installed`. The `identifier` property in the payment provider service is used when the payment provider is added to the database. + * + * The value of this property is also used to reference the payment provider throughout Medusa. + * For example, it is used to [add a payment provider](https://docs.medusajs.com/api/admin#regions_postregionsregionpaymentproviders) to a region. + * + * ```ts + * class MyPaymentService extends AbstractPaymentProvider { + * static identifier = "my-payment" + * // ... + * } + * ``` + */ + public static identifier: string + + /** + * @ignore + * + * Return a unique identifier to retrieve the payment plugin provider + */ + public getIdentifier(): string { + const ctr = this.constructor as typeof AbstractPaymentProvider + + if (!ctr.identifier) { + throw new Error(`Missing static property "identifier".`) + } + + return ctr.identifier + } + + abstract capturePayment( + paymentSessionData: Record + ): Promise + + abstract authorizePayment( + paymentSessionData: Record, + context: Record + ): Promise< + | PaymentProviderError + | { + status: PaymentSessionStatus + data: PaymentProviderSessionResponse["data"] + } + > + + abstract cancelPayment( + paymentSessionData: Record + ): Promise + + abstract initiatePayment( + context: PaymentProviderContext + ): Promise + + abstract deletePayment( + paymentSessionData: Record + ): Promise + + abstract getPaymentStatus( + paymentSessionData: Record + ): Promise + + abstract refundPayment( + paymentSessionData: Record, + refundAmount: number + ): Promise + + abstract retrievePayment( + paymentSessionData: Record + ): Promise + + abstract updatePayment( + context: PaymentProviderContext + ): Promise +} + +export function isPaymentProviderError(obj: any): obj is PaymentProviderError { + return obj && typeof obj === "object" && obj.error && obj.code && obj.detail +} diff --git a/packages/utils/src/payment/index.ts b/packages/utils/src/payment/index.ts index e5e483a8d5..35e33262da 100644 --- a/packages/utils/src/payment/index.ts +++ b/packages/utils/src/payment/index.ts @@ -1,2 +1,3 @@ export * from "./payment-collection" export * from "./payment-session" +export * from "./abstract-payment-provider"