From f43e9f0f20a8b0637252951b2bdfed4d42fb9f5e Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Tue, 21 Feb 2023 09:48:32 +0100 Subject: [PATCH] feat(medusa): Load PaymentProcessors + integrate in PaymentProviderService (#2978) * feat: Add payment process support into the loader and payment provider * WIP * feat: continue payment provider alignment * fix tests and defer payment service resolution * continue to add support to payment provider * continue to add support to payment provider * fix fixtures * chore: add updateSessionData unsupported error * chore: Adress feedback * chore: Adress feedback * chore: fix default loader * cleanup * cleanup * fix unit tests * Create purple-sloths-confess.md * address feedback * minor changes * fix unit test --------- Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/purple-sloths-confess.md | 8 + packages/medusa/src/interfaces/index.ts | 1 + .../src/interfaces/payment-processor.ts | 114 +- .../medusa/src/interfaces/payment-service.ts | 6 +- .../src/loaders/__tests__/default.spec.ts | 17 +- packages/medusa/src/loaders/defaults.ts | 223 +++- .../medusa/src/loaders/helpers/plugins.ts | 62 + packages/medusa/src/loaders/plugins.ts | 25 +- .../services/__fixtures__/payment-provider.ts | 85 +- .../services/__tests__/payment-provider.js | 360 ------ .../services/__tests__/payment-provider.ts | 1009 +++++++++++++++++ .../medusa/src/services/payment-provider.ts | 335 +++++- packages/medusa/src/types/payment.ts | 2 + 13 files changed, 1714 insertions(+), 533 deletions(-) create mode 100644 .changeset/purple-sloths-confess.md create mode 100644 packages/medusa/src/loaders/helpers/plugins.ts delete mode 100644 packages/medusa/src/services/__tests__/payment-provider.js create mode 100644 packages/medusa/src/services/__tests__/payment-provider.ts diff --git a/.changeset/purple-sloths-confess.md b/.changeset/purple-sloths-confess.md new file mode 100644 index 0000000000..6242866238 --- /dev/null +++ b/.changeset/purple-sloths-confess.md @@ -0,0 +1,8 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): Load PaymentProcessors +- Add loading of PaymentProcessors +- Add PaymentProcessor support in the payment-provider +- Add backward compatibility for the PaymentService diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index d6984c55dc..13ebe68d05 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -10,4 +10,5 @@ export * from "./models/base-entity" export * from "./models/soft-deletable-entity" export * from "./search-service" export * from "./payment-service" +export * from "./payment-processor" export * from "./services" diff --git a/packages/medusa/src/interfaces/payment-processor.ts b/packages/medusa/src/interfaces/payment-processor.ts index fe9da026a9..7d69c16f74 100644 --- a/packages/medusa/src/interfaces/payment-processor.ts +++ b/packages/medusa/src/interfaces/payment-processor.ts @@ -6,21 +6,21 @@ export type PaymentProcessorContext = { email: string currency_code: string amount: number - resource_id?: string + resource_id: string customer?: Customer context: Record paymentSessionData: Record } export type PaymentProcessorSessionResponse = { - update_requests: { customer_metadata: Record } + update_requests?: { customer_metadata?: Record } session_data: Record } export interface PaymentProcessorError { error: string - code: number - details: any + code?: string + detail?: any } /** @@ -51,42 +51,60 @@ export interface PaymentProcessor { */ updatePayment( context: PaymentProcessorContext - ): Promise + ): Promise /** * Refund an existing session - * @param context + * @param paymentSessionData + * @param refundAmount */ refundPayment( - context: PaymentProcessorContext - ): Promise + paymentSessionData: Record, + refundAmount: number + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > /** * Authorize an existing session if it is not already authorized + * @param paymentSessionData * @param context */ authorizePayment( - context: PaymentProcessorContext - ): Promise + paymentSessionData: Record, + context: Record + ): Promise< + | PaymentProcessorError + | { + status: PaymentSessionStatus + data: PaymentProcessorSessionResponse["session_data"] + } + > /** * Capture an existing session - * @param context + * @param paymentSessionData */ capturePayment( - context: PaymentProcessorContext - ): Promise + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > /** * Delete an existing session */ - deletePayment(paymentId: string): Promise + deletePayment( + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > /** * Retrieve an existing session */ retrievePayment( - paymentId: string + paymentSessionData: Record ): Promise< PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] > @@ -94,12 +112,18 @@ export interface PaymentProcessor { /** * Cancel an existing session */ - cancelPayment(paymentId: string): Promise + cancelPayment( + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > /** * Return the status of the session */ - getPaymentStatus(paymentId: string): Promise + getPaymentStatus( + paymentSessionData: Record + ): Promise } /** @@ -111,7 +135,7 @@ export abstract class AbstractPaymentProcessor implements PaymentProcessor { protected readonly config?: Record // eslint-disable-next-line @typescript-eslint/no-empty-function ) {} - protected static identifier: string + public static identifier: string public getIdentifier(): string { const ctr = this.constructor as typeof AbstractPaymentProcessor @@ -126,40 +150,58 @@ export abstract class AbstractPaymentProcessor implements PaymentProcessor { abstract init(): Promise abstract capturePayment( - context: PaymentProcessorContext - ): Promise + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > abstract authorizePayment( - context: PaymentProcessorContext - ): Promise + paymentSessionData: Record, + context: Record + ): Promise< + | PaymentProcessorError + | { + status: PaymentSessionStatus + data: PaymentProcessorSessionResponse["session_data"] + } + > abstract cancelPayment( - paymentId: string - ): Promise + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > abstract initiatePayment( context: PaymentProcessorContext ): Promise abstract deletePayment( - paymentId: string - ): Promise + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > - abstract getPaymentStatus(paymentId: string): Promise + abstract getPaymentStatus( + paymentSessionData: Record + ): Promise abstract refundPayment( - context: PaymentProcessorContext - ): Promise + paymentSessionData: Record, + refundAmount: number + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > abstract retrievePayment( - paymentId: string + paymentSessionData: Record ): Promise< PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] > abstract updatePayment( context: PaymentProcessorContext - ): Promise + ): Promise } /** @@ -169,3 +211,13 @@ export abstract class AbstractPaymentProcessor implements PaymentProcessor { export function isPaymentProcessor(obj: unknown): boolean { return obj instanceof AbstractPaymentProcessor } + +/** + * Utility function to determine if an object is a processor error + * @param obj + */ +export function isPaymentProcessorError( + obj: any +): obj is PaymentProcessorError { + return obj && typeof obj === "object" && (obj.error || obj.code || obj.detail) +} diff --git a/packages/medusa/src/interfaces/payment-service.ts b/packages/medusa/src/interfaces/payment-service.ts index bf13bacd2b..a32d2c1c3d 100644 --- a/packages/medusa/src/interfaces/payment-service.ts +++ b/packages/medusa/src/interfaces/payment-service.ts @@ -21,11 +21,13 @@ export type PaymentContext = { email: string shipping_address: Address | null shipping_methods: ShippingMethod[] + billing_address?: Address | null } currency_code: string amount: number - resource_id?: string + resource_id: string customer?: Customer + paymentSessionData: Record } export type PaymentSessionResponse = { @@ -148,7 +150,7 @@ export abstract class AbstractPaymentService super(container, config) } - protected static identifier: string + public static identifier: string public getIdentifier(): string { if (!(this.constructor as typeof AbstractPaymentService).identifier) { diff --git a/packages/medusa/src/loaders/__tests__/default.spec.ts b/packages/medusa/src/loaders/__tests__/default.spec.ts index c8d6ec517a..6b3278da47 100644 --- a/packages/medusa/src/loaders/__tests__/default.spec.ts +++ b/packages/medusa/src/loaders/__tests__/default.spec.ts @@ -1,12 +1,16 @@ import { asValue, createContainer } from "awilix" import { MockManager, MockRepository } from "medusa-test-utils" import { StoreServiceMock } from "../../services/__mocks__/store" -import { ShippingProfileServiceMock } from "../../services/__mocks__/shipping-profile" +import { + ShippingProfileServiceMock +} from "../../services/__mocks__/shipping-profile" import Logger from "../logger" import featureFlagsLoader from "../feature-flags" import { default as defaultLoader } from "../defaults" import { SalesChannelServiceMock } from "../../services/__mocks__/sales-channel" -import { PaymentProviderServiceMock } from "../../services/__mocks__/payment-provider" +import { + PaymentProviderServiceMock +} from "../../services/__mocks__/payment-provider" describe("default", () => { describe("sales channel default", () => { @@ -40,14 +44,23 @@ describe("default", () => { paymentProviderService: asValue(PaymentProviderServiceMock), notificationProviders: asValue([]), notificationService: asValue({ + withTransaction: function () { + return this + }, registerInstalledProviders: jest.fn(), }), fulfillmentProviders: asValue([]), fulfillmentProviderService: asValue({ + withTransaction: function () { + return this + }, registerInstalledProviders: jest.fn(), }), taxProviders: asValue([]), taxProviderService: asValue({ + withTransaction: function () { + return this + }, registerInstalledProviders: jest.fn(), }), }) diff --git a/packages/medusa/src/loaders/defaults.ts b/packages/medusa/src/loaders/defaults.ts index 133e0c6d1c..805ebc291d 100644 --- a/packages/medusa/src/loaders/defaults.ts +++ b/packages/medusa/src/loaders/defaults.ts @@ -21,7 +21,11 @@ import { import { CurrencyRepository } from "../repositories/currency" import { FlagRouter } from "../utils/flag-router" import SalesChannelFeatureFlag from "./feature-flags/sales-channels" -import { AbstractPaymentService, AbstractTaxService } from "../interfaces" +import { + AbstractPaymentProcessor, + AbstractPaymentService, + AbstractTaxService, +} from "../interfaces" const silentResolution = ( container: AwilixContainer, @@ -122,66 +126,167 @@ export default async ({ await entityManager.transaction(async (manager: EntityManager) => { await storeService.withTransaction(manager).create() + const profileServiceTx = profileService.withTransaction(manager) - const payProviders = - silentResolution<(typeof BasePaymentService | AbstractPaymentService)[]>( - container, - "paymentProviders", - logger - ) || [] - const payIds = payProviders.map((p) => p.getIdentifier()) + const context = { container, manager, logger } - const pProviderService = container.resolve( - "paymentProviderService" - ) - await pProviderService.registerInstalledProviders(payIds) + await Promise.all([ + registerPaymentProvider(context), + registerPaymentProcessor(context), + registerNotificationProvider(context), + registerFulfillmentProvider(context), + registerTaxProvider(context), + profileServiceTx.createDefault(), + profileServiceTx.createGiftCardDefault(), + (async () => { + const isSalesChannelEnabled = featureFlagRouter.isFeatureEnabled( + SalesChannelFeatureFlag.key + ) + if (isSalesChannelEnabled) { + return await salesChannelService + .withTransaction(manager) + .createDefault() + } - const notiProviders = - silentResolution( - container, - "notificationProviders", - logger - ) || [] - const notiIds = notiProviders.map((p) => p.getIdentifier()) - - const nProviderService = container.resolve( - "notificationService" - ) - await nProviderService.registerInstalledProviders(notiIds) - - const fulfilProviders = - silentResolution( - container, - "fulfillmentProviders", - logger - ) || [] - const fulfilIds = fulfilProviders.map((p) => p.getIdentifier()) - - const fProviderService = container.resolve( - "fulfillmentProviderService" - ) - await fProviderService.registerInstalledProviders(fulfilIds) - - const taxProviders = - silentResolution( - container, - "taxProviders", - logger - ) || [] - const taxIds = taxProviders.map((p) => p.getIdentifier()) - - const tProviderService = - container.resolve("taxProviderService") - await tProviderService.registerInstalledProviders(taxIds) - - await profileService.withTransaction(manager).createDefault() - await profileService.withTransaction(manager).createGiftCardDefault() - - const isSalesChannelEnabled = featureFlagRouter.isFeatureEnabled( - SalesChannelFeatureFlag.key - ) - if (isSalesChannelEnabled) { - await salesChannelService.withTransaction(manager).createDefault() - } + return + })(), + ]) }) } + +async function registerPaymentProvider({ + manager, + container, + logger, +}: { + container: AwilixContainer + manager: EntityManager + logger: Logger +}): Promise { + const payProviders = ( + silentResolution< + ( + | typeof BasePaymentService + | AbstractPaymentService + | AbstractPaymentProcessor + )[] + >(container, "paymentProviders", logger) || [] + ).filter((provider) => !(provider instanceof AbstractPaymentProcessor)) + + const payIds = payProviders.map((paymentProvider) => { + return paymentProvider.getIdentifier() + }) + + const pProviderService = container.resolve( + "paymentProviderService" + ) + await pProviderService + .withTransaction(manager) + .registerInstalledProviders(payIds) +} + +async function registerPaymentProcessor({ + manager, + container, + logger, +}: { + container: AwilixContainer + manager: EntityManager + logger: Logger +}): Promise { + const payProviders = ( + silentResolution< + ( + | typeof BasePaymentService + | AbstractPaymentService + | AbstractPaymentProcessor + )[] + >(container, "paymentProviders", logger) || [] + ).filter((provider) => provider instanceof AbstractPaymentProcessor) + + const payIds: string[] = [] + await Promise.all( + payProviders.map((paymentProvider) => { + payIds.push(paymentProvider.getIdentifier()) + return paymentProvider.init() + }) + ) + + const pProviderService = container.resolve( + "paymentProviderService" + ) + await pProviderService + .withTransaction(manager) + .registerInstalledProviders(payIds) +} + +async function registerNotificationProvider({ + manager, + container, + logger, +}: { + container: AwilixContainer + manager: EntityManager + logger: Logger +}): Promise { + const notiProviders = + silentResolution( + container, + "notificationProviders", + logger + ) || [] + const notiIds = notiProviders.map((p) => p.getIdentifier()) + + const nProviderService = container.resolve( + "notificationService" + ) + await nProviderService + .withTransaction(manager) + .registerInstalledProviders(notiIds) +} + +async function registerFulfillmentProvider({ + manager, + container, + logger, +}: { + container: AwilixContainer + manager: EntityManager + logger: Logger +}): Promise { + const fulfilProviders = + silentResolution( + container, + "fulfillmentProviders", + logger + ) || [] + const fulfilIds = fulfilProviders.map((p) => p.getIdentifier()) + + const fProviderService = container.resolve( + "fulfillmentProviderService" + ) + await fProviderService + .withTransaction(manager) + .registerInstalledProviders(fulfilIds) +} + +async function registerTaxProvider({ + manager, + container, + logger, +}: { + container: AwilixContainer + manager: EntityManager + logger: Logger +}): Promise { + const taxProviders = + silentResolution(container, "taxProviders", logger) || + [] + const taxIds = taxProviders.map((p) => p.getIdentifier()) + + const tProviderService = + container.resolve("taxProviderService") + await tProviderService + .withTransaction(manager) + .registerInstalledProviders(taxIds) +} diff --git a/packages/medusa/src/loaders/helpers/plugins.ts b/packages/medusa/src/loaders/helpers/plugins.ts new file mode 100644 index 0000000000..539b1d412f --- /dev/null +++ b/packages/medusa/src/loaders/helpers/plugins.ts @@ -0,0 +1,62 @@ +import { ClassConstructor, MedusaContainer } from "../../types/global" +import { + AbstractPaymentProcessor, + AbstractPaymentService, + isPaymentProcessor, + isPaymentService, +} from "../../interfaces" +import { aliasTo, asFunction } from "awilix" + +type Context = { + container: MedusaContainer + pluginDetails: Record + registrationName: string +} + +export function registerPaymentServiceFromClass( + klass: ClassConstructor, + context: Context +): void { + if (!isPaymentService(klass.prototype)) { + return + } + + const { container, pluginDetails, registrationName } = context + + container.registerAdd( + "paymentProviders", + asFunction((cradle) => new klass(cradle, pluginDetails.options)) + ) + + container.register({ + [registrationName]: asFunction( + (cradle) => new klass(cradle, pluginDetails.options) + ), + [`pp_${(klass as unknown as typeof AbstractPaymentService).identifier}`]: + aliasTo(registrationName), + }) +} + +export function registerPaymentProcessorFromClass( + klass: ClassConstructor, + context: Context +): void { + if (!isPaymentProcessor(klass.prototype)) { + return + } + + const { container, pluginDetails, registrationName } = context + + container.registerAdd( + "paymentProviders", + asFunction((cradle) => new klass(cradle, pluginDetails.options)) + ) + + container.register({ + [registrationName]: asFunction( + (cradle) => new klass(cradle, pluginDetails.options) + ), + [`pp_${(klass as unknown as typeof AbstractPaymentProcessor).identifier}`]: + aliasTo(registrationName), + }) +} diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index ed17715704..c178189901 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -20,7 +20,6 @@ import { isCartCompletionStrategy, isFileService, isNotificationService, - isPaymentService, isPriceSelectionStrategy, isSearchService, isTaxCalculationStrategy, @@ -35,6 +34,10 @@ import { } from "../types/global" import formatRegistrationName from "../utils/format-registration-name" import logger from "./logger" +import { + registerPaymentProcessorFromClass, + registerPaymentServiceFromClass, +} from "./helpers/plugins" type Options = { rootDirectory: string @@ -371,22 +374,12 @@ export async function registerServices( throw new Error(message) } - if (isPaymentService(loaded.prototype)) { - // Register our payment providers to paymentProviders - container.registerAdd( - "paymentProviders", - asFunction((cradle) => new loaded(cradle, pluginDetails.options)) - ) + const context = { container, pluginDetails, registrationName: name } - // Add the service directly to the container in order to make simple - // resolution if we already know which payment provider we need to use - container.register({ - [name]: asFunction( - (cradle) => new loaded(cradle, pluginDetails.options) - ), - [`pp_${loaded.identifier}`]: aliasTo(name), - }) - } else if (loaded.prototype instanceof OauthService) { + registerPaymentServiceFromClass(loaded, context) + registerPaymentProcessorFromClass(loaded, context) + + if (loaded.prototype instanceof OauthService) { const appDetails = loaded.getAppDetails(pluginDetails.options) const oauthService = diff --git a/packages/medusa/src/services/__fixtures__/payment-provider.ts b/packages/medusa/src/services/__fixtures__/payment-provider.ts index c33ff60d1e..9c826a6c74 100644 --- a/packages/medusa/src/services/__fixtures__/payment-provider.ts +++ b/packages/medusa/src/services/__fixtures__/payment-provider.ts @@ -1,13 +1,22 @@ -import { asClass, asValue, createContainer } from "awilix" +import { asClass, asFunction, asValue, createContainer } from "awilix" import { MockManager, MockRepository } from "medusa-test-utils" import PaymentProviderService from "../payment-provider"; import { PaymentProviderServiceMock } from "../__mocks__/payment-provider"; import { CustomerServiceMock } from "../__mocks__/customer"; import { FlagRouter } from "../../utils/flag-router"; import Logger from "../../loaders/logger"; +import { + AbstractPaymentProcessor, + PaymentProcessorContext, + PaymentProcessorError, + PaymentProcessorSessionResponse +} from "../../interfaces"; +import { PaymentSessionStatus } from "../../models"; +import { PaymentServiceMock } from "../__mocks__/payment"; export const defaultContainer = createContainer() defaultContainer.register("paymentProviderService", asClass(PaymentProviderService)) +defaultContainer.register("paymentService", asValue(PaymentServiceMock)) defaultContainer.register("manager", asValue(MockManager)) defaultContainer.register("paymentSessionRepository", asValue(MockRepository())) defaultContainer.register("paymentProviderRepository", asValue(PaymentProviderServiceMock)) @@ -16,3 +25,77 @@ defaultContainer.register("refundRepository", asValue(MockRepository())) defaultContainer.register("customerService", asValue(CustomerServiceMock)) defaultContainer.register("featureFlagRouter", asValue(new FlagRouter({}))) defaultContainer.register("logger", asValue(Logger)) +defaultContainer.register("pp_payment_processor", asFunction((cradle) => new PaymentProcessor(cradle))) + +export class PaymentProcessor extends AbstractPaymentProcessor { + constructor(container) { + super(container); + } + authorizePayment(context: PaymentProcessorContext): Promise< + | PaymentProcessorError + | { + status: PaymentSessionStatus + data: PaymentProcessorSessionResponse["session_data"] + } + > { + return Promise.resolve({ } as any); + } + + getPaymentStatus(paymentSessionData: Record): Promise { + return Promise.resolve(PaymentSessionStatus.PENDING); + } + + init(): Promise { + return Promise.resolve(undefined); + } + + initiatePayment(context: PaymentProcessorContext): Promise { + return Promise.resolve({ } as PaymentProcessorSessionResponse); + } + + retrievePayment(paymentSessionData: Record): Promise { + return Promise.resolve({ }); + } + + updatePayment(context: PaymentProcessorContext): Promise { + return Promise.resolve(undefined); + } + + capturePayment(paymentSessionData: Record): Promise { + return Promise.resolve({ }); + } + + refundPayment(paymentSessionData: Record): Promise { + return Promise.resolve({}); + } + + cancelPayment(paymentSessionData: Record): Promise { + return Promise.resolve({}); + } + + deletePayment(paymentSessionData: Record): Promise { + return Promise.resolve({}); + } +} + +export const defaultPaymentSessionInputData = { + provider_id: "payment_processor", + cart: { + context: {}, + id: "cart-test", + email: "test@medusajs.com", + shipping_address: {}, + shipping_methods: [], + billing_address: { + first_name: "Virgil", + last_name: "Van Dijk", + address_1: "24 Dunks Drive", + city: "Los Angeles", + country_code: "US", + province: "CA", + postal_code: "93011", + }, + }, + currency_code: "usd", + amount: 1000, +} \ No newline at end of file diff --git a/packages/medusa/src/services/__tests__/payment-provider.js b/packages/medusa/src/services/__tests__/payment-provider.js deleted file mode 100644 index b2118c7fc1..0000000000 --- a/packages/medusa/src/services/__tests__/payment-provider.js +++ /dev/null @@ -1,360 +0,0 @@ -import { asValue, createContainer } from "awilix" -import { MockRepository } from "medusa-test-utils" -import PaymentProviderService from "../payment-provider" -import { defaultContainer } from "../__fixtures__/payment-provider" -import { testPayServiceMock } from "../__mocks__/test-pay" - -describe("PaymentProviderService", () => { - describe("retrieveProvider", () => { - const container = createContainer({}, defaultContainer) - container.register("pp_default_provider", asValue("good")) - - const providerService = container.resolve("paymentProviderService") - - it("successfully retrieves payment provider", () => { - const provider = providerService.retrieveProvider("default_provider") - expect(provider).toEqual("good") - }) - - it("fails when payment provider not found", () => { - try { - providerService.retrieveProvider("unregistered") - } catch (err) { - expect(err.message).toEqual( - "Could not find a payment provider with id: unregistered" - ) - } - }) - }) - - describe("createSession", () => { - const container = createContainer({}, defaultContainer) - container.register( - "pp_default_provider", - asValue({ - withTransaction: function () { - return this - }, - createPayment: jest.fn().mockReturnValue(Promise.resolve({})), - }) - ) - - const providerService = container.resolve("paymentProviderService") - - it("successfully creates session", async () => { - await providerService.createSession("default_provider", { - object: "cart", - region: { - currency_code: "usd", - }, - total: 100, - }) - - const defaultProvider = container.resolve("pp_default_provider") - - expect(defaultProvider.createPayment).toBeCalledTimes(1) - expect(defaultProvider.createPayment).toBeCalledWith({ - amount: 100, - object: "cart", - total: 100, - region: { - currency_code: "usd", - }, - cart: { - context: undefined, - email: undefined, - id: undefined, - shipping_address: undefined, - shipping_methods: undefined, - }, - currency_code: "usd", - }) - }) - }) - - describe("updateSession", () => { - const container = createContainer({}, defaultContainer) - container.register( - "paymentSessionRepository", - asValue( - MockRepository({ - findOne: () => - Promise.resolve({ - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }), - }) - ) - ) - container.register( - "pp_default_provider", - asValue({ - withTransaction: function () { - return this - }, - updatePayment: jest.fn().mockReturnValue(Promise.resolve({})), - }) - ) - - const providerService = container.resolve("paymentProviderService") - - it("successfully creates session", async () => { - await providerService.updateSession( - { - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }, - { - object: "cart", - total: 100, - } - ) - - const defaultProvider = container.resolve("pp_default_provider") - - expect(defaultProvider.updatePayment).toBeCalledTimes(1) - expect(defaultProvider.updatePayment).toBeCalledWith( - { id: "1234" }, - { - object: "cart", - amount: 100, - total: 100, - cart: { - context: undefined, - email: undefined, - id: undefined, - shipping_address: undefined, - shipping_methods: undefined, - }, - currency_code: undefined, - } - ) - }) - }) -}) - -describe(`PaymentProviderService`, () => { - const container = createContainer({}, defaultContainer) - container.register("pp_default_provider", asValue(testPayServiceMock)) - container.register( - "paymentSessionRepository", - asValue( - MockRepository({ - findOne: () => - Promise.resolve({ - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }), - }) - ) - ) - container.register( - "paymentRepository", - asValue( - MockRepository({ - findOne: () => - Promise.resolve({ - id: "pay_jadazdjk", - provider_id: "default_provider", - data: { - id: "1234", - }, - }), - find: () => - Promise.resolve([ - { - id: "pay_jadazdjk", - provider_id: "default_provider", - data: { - id: "1234", - }, - captured_at: new Date(), - amount: 100, - amount_refunded: 0, - }, - ]), - }) - ) - ) - - const providerService = container.resolve("paymentProviderService") - - afterEach(() => { - jest.clearAllMocks() - }) - - it("successfully retrieves payment provider", () => { - const provider = providerService.retrieveProvider("default_provider") - expect(provider.identifier).toEqual("test-pay") - }) - - it("successfully creates session", async () => { - await providerService.createSession("default_provider", { - object: "cart", - region: { - currency_code: "usd", - }, - total: 100, - }) - - expect(testPayServiceMock.createPayment).toBeCalledTimes(1) - expect(testPayServiceMock.createPayment).toBeCalledWith({ - amount: 100, - object: "cart", - total: 100, - region: { - currency_code: "usd", - }, - cart: { - context: undefined, - email: undefined, - id: undefined, - shipping_address: undefined, - shipping_methods: undefined, - }, - currency_code: "usd", - }) - }) - - it("successfully update session", async () => { - await providerService.updateSession( - { - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }, - { - object: "cart", - total: 100, - } - ) - - expect(testPayServiceMock.updatePayment).toBeCalledTimes(1) - expect(testPayServiceMock.updatePayment).toBeCalledWith( - { id: "1234" }, - { - amount: 100, - object: "cart", - total: 100, - cart: { - context: undefined, - email: undefined, - id: undefined, - shipping_address: undefined, - shipping_methods: undefined, - }, - } - ) - }) - - it("successfully refresh session", async () => { - await providerService.refreshSession( - { - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }, - { - provider_id: "default_provider", - amount: 100, - currency_code: "usd", - } - ) - - expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) - expect(testPayServiceMock.createPayment).toBeCalledTimes(1) - }) - - it("successfully delete session", async () => { - await providerService.deleteSession({ - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }) - - expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) - }) - - it("successfully delete session", async () => { - await providerService.deleteSession({ - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }) - - expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) - }) - - it("successfully authorize payment", async () => { - await providerService.authorizePayment( - { - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }, - {} - ) - - expect(testPayServiceMock.authorizePayment).toBeCalledTimes(1) - }) - - it("successfully update session data", async () => { - await providerService.updateSessionData( - { - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - }, - {} - ) - - expect(testPayServiceMock.updatePaymentData).toBeCalledTimes(1) - }) - - it("successfully cancel payment", async () => { - await providerService.cancelPayment({ - id: "pay_jadazdjk", - }) - expect(testPayServiceMock.cancelPayment).toBeCalledTimes(1) - }) - - it("successfully capture payment", async () => { - await providerService.capturePayment({ - id: "pay_jadazdjk", - }) - expect(testPayServiceMock.capturePayment).toBeCalledTimes(1) - }) - - it("successfully refund payment", async () => { - await providerService.refundPayment( - [ - { - id: "pay_jadazdjk", - }, - ], - 50 - ) - expect(testPayServiceMock.refundPayment).toBeCalledTimes(1) - }) -}) diff --git a/packages/medusa/src/services/__tests__/payment-provider.ts b/packages/medusa/src/services/__tests__/payment-provider.ts new file mode 100644 index 0000000000..ce3a7d42a2 --- /dev/null +++ b/packages/medusa/src/services/__tests__/payment-provider.ts @@ -0,0 +1,1009 @@ +import { asValue, createContainer } from "awilix" +import { MockRepository } from "medusa-test-utils" +import PaymentProviderService from "../payment-provider" +import { + defaultContainer, + defaultPaymentSessionInputData, + PaymentProcessor, +} from "../__fixtures__/payment-provider" +import { testPayServiceMock } from "../__mocks__/test-pay" +import { EOL } from "os" +import { PaymentSessionStatus, RefundReason } from "../../models"; + +describe(`PaymentProviderService`, () => { + const container = createContainer({}, defaultContainer) + container.register("pp_default_provider", asValue(testPayServiceMock)) + container + .register( + "paymentSessionRepository", + asValue( + MockRepository({ + findOne: () => + Promise.resolve({ + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }), + }) + ) + ) + .register( + "paymentRepository", + asValue( + MockRepository({ + findOne: () => + Promise.resolve({ + id: "pay_jadazdjk", + provider_id: "default_provider", + data: { + id: "1234", + }, + }), + find: () => + Promise.resolve([ + { + id: "pay_jadazdjk", + provider_id: "default_provider", + data: { + id: "1234", + }, + captured_at: new Date(), + amount: 100, + amount_refunded: 0, + }, + ]), + }) + ) + ) + + const providerService = container.resolve("paymentProviderService") + + afterEach(() => { + jest.clearAllMocks() + }) + + it("successfully retrieves payment provider", () => { + const provider = providerService.retrieveProvider("default_provider") + expect(provider.identifier).toEqual("test-pay") + }) + + it("successfully creates session", async () => { + await providerService.createSession("default_provider", { + object: "cart", + region: { + currency_code: "usd", + }, + total: 100, + }) + + expect(testPayServiceMock.createPayment).toBeCalledTimes(1) + expect(testPayServiceMock.createPayment).toBeCalledWith({ + amount: 100, + object: "cart", + total: 100, + region: { + currency_code: "usd", + }, + cart: { + context: undefined, + email: undefined, + id: undefined, + shipping_address: undefined, + shipping_methods: undefined, + }, + currency_code: "usd", + }) + }) + + it("successfully update session", async () => { + await providerService.updateSession( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + { + object: "cart", + total: 100, + } + ) + + expect(testPayServiceMock.updatePayment).toBeCalledTimes(1) + expect(testPayServiceMock.updatePayment).toBeCalledWith( + { id: "1234" }, + { + amount: 100, + object: "cart", + total: 100, + cart: { + context: undefined, + email: undefined, + id: undefined, + shipping_address: undefined, + shipping_methods: undefined, + }, + } + ) + }) + + it("successfully refresh session", async () => { + await providerService.refreshSession( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + { + provider_id: "default_provider", + amount: 100, + currency_code: "usd", + cart: { + id: "cart-test" + } + } + ) + + expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) + expect(testPayServiceMock.createPayment).toBeCalledTimes(1) + }) + + it("successfully delete session", async () => { + await providerService.deleteSession({ + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }) + + expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) + }) + + it("successfully delete session", async () => { + await providerService.deleteSession({ + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }) + + expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) + }) + + it("successfully authorize payment", async () => { + await providerService.authorizePayment( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + {} + ) + + expect(testPayServiceMock.authorizePayment).toBeCalledTimes(1) + }) + + it("successfully update session data", async () => { + await providerService.updateSessionData( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + {} + ) + + expect(testPayServiceMock.updatePaymentData).toBeCalledTimes(1) + }) + + it("successfully cancel payment", async () => { + await providerService.cancelPayment({ + id: "pay_jadazdjk", + }) + expect(testPayServiceMock.cancelPayment).toBeCalledTimes(1) + }) + + it("successfully capture payment", async () => { + await providerService.capturePayment({ + id: "pay_jadazdjk", + }) + expect(testPayServiceMock.capturePayment).toBeCalledTimes(1) + }) + + it("successfully refund payment", async () => { + await providerService.refundPayment( + [ + { + id: "pay_jadazdjk", + }, + ], + 50 + ) + expect(testPayServiceMock.refundPayment).toBeCalledTimes(1) + }) +}) + +describe("PaymentProviderService using AbstractPaymentProcessor", () => { + const paymentProcessorResolutionKey = "pp_payment_processor" + const paymentServiceResolutionKey = "paymentProviderService" + const paymentProviderId = "payment_processor" + + describe("createSession", () => { + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.initiatePayment = jest + .fn() + .mockReturnValue(Promise.resolve({ session_data: {} })) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully creates session", async () => { + await providerService.createSession(defaultPaymentSessionInputData) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.initiatePayment).toBeCalledTimes(1) + expect(provider.initiatePayment).toBeCalledWith({ + billing_address: defaultPaymentSessionInputData.cart.billing_address, + email: defaultPaymentSessionInputData.cart.email, + currency_code: defaultPaymentSessionInputData.currency_code, + amount: defaultPaymentSessionInputData.amount, + resource_id: "cart-test", + customer: undefined, + context: {}, + paymentSessionData: {}, + }) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.initiatePayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .createSession(defaultPaymentSessionInputData) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("refreshSession", () => { + const sessionId = "test-session" + const externalId = "external-id" + const paymentSessionData = { id: externalId } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.deletePayment = jest + .fn() + .mockReturnValue(Promise.resolve()) + mockPaymentProcessor.initiatePayment = jest + .fn() + .mockReturnValue(Promise.resolve({ session_data: paymentSessionData })) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentSessionRepository", + asValue( + MockRepository({ + findOne: jest + .fn() + .mockImplementation(async () => ({ data: paymentSessionData })), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully refresh a session", async () => { + await providerService.refreshSession( + { + id: sessionId, + data: paymentSessionData, + provider_id: paymentProviderId, + }, + defaultPaymentSessionInputData + ) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.deletePayment).toBeCalledTimes(1) + expect(provider.deletePayment).toBeCalledWith({ id: externalId }) + + expect(provider.initiatePayment).toBeCalledTimes(1) + expect(provider.initiatePayment).toBeCalledWith({ + billing_address: defaultPaymentSessionInputData.cart.billing_address, + email: defaultPaymentSessionInputData.cart.email, + currency_code: defaultPaymentSessionInputData.currency_code, + amount: defaultPaymentSessionInputData.amount, + resource_id: "cart-test", + customer: undefined, + context: {}, + paymentSessionData: {}, + }) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.deletePayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .refreshSession( + { + id: sessionId, + data: paymentSessionData, + provider_id: paymentProviderId, + }, + defaultPaymentSessionInputData + ) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("updateSession", () => { + const sessionId = "test-session" + const externalId = "external-id" + const paymentSessionData = { id: externalId } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.updatePayment = jest + .fn() + .mockReturnValue(Promise.resolve({ session_data: paymentSessionData })) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentSessionRepository", + asValue( + MockRepository({ + findOne: jest + .fn() + .mockImplementation(async () => ({ data: paymentSessionData })), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully update a session", async () => { + await providerService.updateSession( + { + id: sessionId, + data: paymentSessionData, + provider_id: paymentProviderId, + }, + defaultPaymentSessionInputData + ) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.updatePayment).toBeCalledTimes(1) + expect(provider.updatePayment).toBeCalledWith({ + billing_address: defaultPaymentSessionInputData.cart.billing_address, + email: defaultPaymentSessionInputData.cart.email, + currency_code: defaultPaymentSessionInputData.currency_code, + amount: defaultPaymentSessionInputData.amount, + resource_id: "cart-test", + customer: undefined, + context: {}, + paymentSessionData, + }) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.updatePayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .updateSession( + { + id: sessionId, + data: paymentSessionData, + provider_id: paymentProviderId, + }, + defaultPaymentSessionInputData + ) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("deleteSession", () => { + const sessionId = "test-session" + const externalId = "external-id" + const paymentSessionData = { id: externalId } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.deletePayment = jest.fn() + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentSessionRepository", + asValue( + MockRepository({ + findOne: jest + .fn() + .mockImplementation(async () => ({ data: paymentSessionData })), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully delete a session", async () => { + await providerService.deleteSession({ + id: sessionId, + data: paymentSessionData, + provider_id: paymentProviderId, + }) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.deletePayment).toBeCalledTimes(1) + expect(provider.deletePayment).toBeCalledWith(paymentSessionData) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.deletePayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .deleteSession({ + id: sessionId, + data: paymentSessionData, + provider_id: paymentProviderId, + }) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("createPayment", () => { + const sessionId = "test-session" + const externalId = "external-id" + const paymentInput = { + cart_id: defaultPaymentSessionInputData.cart.id, + amount: defaultPaymentSessionInputData.amount, + currency_code: defaultPaymentSessionInputData.currency_code, + provider_id: defaultPaymentSessionInputData.provider_id, + payment_session: { + id: sessionId, + data: { id: externalId } + } + } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.retrievePayment = jest.fn().mockReturnValue(Promise.resolve({})) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully create a payment", async () => { + await providerService.createPayment(paymentInput) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.retrievePayment).toBeCalledTimes(1) + expect(provider.retrievePayment).toBeCalledWith(paymentInput.payment_session.data) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.retrievePayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .createPayment(paymentInput) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("authorizePayment", () => { + const externalId = "external-id" + const paymentSession = { + id: "test-session", + data: { id: externalId }, + provider_id: paymentProviderId + } + const context = { ip: "0.0.0.0" } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.authorizePayment = jest.fn().mockReturnValue(Promise.resolve({})) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentSessionRepository", + asValue( + MockRepository({ + findOne: jest + .fn() + .mockImplementation(async () => ({ data: {} })), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully authorize a payment", async () => { + await providerService.authorizePayment(paymentSession, context) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.authorizePayment).toBeCalledTimes(1) + expect(provider.authorizePayment).toBeCalledWith(paymentSession.data, context) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.authorizePayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .authorizePayment(paymentSession, context) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("cancelPayment", () => { + const externalId = "external-id" + const payment = { + id: "payment-id", + data: { id: externalId }, + provider_id: paymentProviderId + } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.cancelPayment = jest.fn().mockReturnValue(Promise.resolve()) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentRepository", + asValue( + MockRepository({ + findOne: jest + .fn() + .mockImplementation(async () => payment), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully cancel a payment", async () => { + await providerService.cancelPayment(payment) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.cancelPayment).toBeCalledTimes(1) + expect(provider.cancelPayment).toBeCalledWith(payment.data) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.cancelPayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .cancelPayment(payment) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("getStatus", () => { + const payment = { + data: { id: "id" }, + provider_id: paymentProviderId + } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.getPaymentStatus = jest + .fn() + .mockReturnValue(Promise.resolve(PaymentSessionStatus.PENDING)) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentRepository", + asValue( + MockRepository({ + findOne: jest + .fn() + .mockImplementation(async () => payment), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully cancel a payment", async () => { + await providerService.getStatus(payment) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.getPaymentStatus).toBeCalledTimes(1) + expect(provider.getPaymentStatus).toBeCalledWith(payment.data) + }) + }) + + describe("capturePayment", () => { + const externalId = "external-id" + const payment = { + data: { id: externalId }, + id: "payment-id", + provider_id: paymentProviderId + } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.capturePayment = jest.fn().mockReturnValue(Promise.resolve(payment.data)) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentRepository", + asValue( + MockRepository({ + findOne: jest + .fn() + .mockImplementation(async () => payment), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + it("successfully capture a payment", async () => { + await providerService.capturePayment(payment) + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.capturePayment).toBeCalledTimes(1) + expect(provider.capturePayment).toBeCalledWith(payment.data) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.capturePayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .capturePayment(payment) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("refundPayment", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + const payments = [{ + id: "p1", + captured_at: new Date(), + data: { id: "id1" }, + amount: 1000, + amount_refunded: 0, + provider_id: paymentProviderId, + }, { + id: "p2", + captured_at: new Date(), + data: { id: "id2" }, + amount: 1000, + amount_refunded: 0, + provider_id: paymentProviderId, + }, { + id: "p3", + captured_at: new Date(), + data: { id: "id3" }, + amount: 1000, + amount_refunded: 1000, // already fully refunded + provider_id: paymentProviderId, + }] + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.refundPayment = jest.fn().mockImplementation(async (data) => data) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + .register( + "paymentRepository", + asValue( + MockRepository({ + find: jest + .fn() + .mockImplementation(async () => payments), + }) + ) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + const paymentRepo = container.resolve("paymentRepository") + + it("successfully refund the payments", async () => { + await providerService.refundPayment(payments, 1500, RefundReason.OTHER, "note") + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.refundPayment).toBeCalledTimes(2) + expect(provider.refundPayment).toHaveBeenNthCalledWith(1, payments[0].data, 1000) + expect(provider.refundPayment).toHaveBeenNthCalledWith(2, payments[1].data, 500) + + expect(paymentRepo.save).toBeCalledTimes(2) + expect(paymentRepo.save).toHaveBeenNthCalledWith(1, expect.objectContaining({ amount_refunded: 1000 })) + expect(paymentRepo.save).toHaveBeenNthCalledWith(2, expect.objectContaining({ amount_refunded: 500 })) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.refundPayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .refundPayment(payments) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) + + describe("refundFromPayment", () => { + const payment = { + id: "p1", + captured_at: new Date(), + data: { id: "id1" }, + amount: 1000, + amount_refunded: 0, + provider_id: paymentProviderId, + } + + const container = createContainer({}, defaultContainer) + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.refundPayment = jest.fn().mockImplementation(async (data) => data) + + container + .register(paymentProcessorResolutionKey, asValue(mockPaymentProcessor)) + + const providerService = container.resolve(paymentServiceResolutionKey) + const paymentRepo = container.resolve("paymentRepository") + + it("successfully refund the payments", async () => { + await providerService.refundFromPayment(payment, 500, RefundReason.OTHER, "note") + + const provider = container.resolve(paymentProcessorResolutionKey) + + expect(provider.refundPayment).toBeCalledTimes(1) + expect(provider.refundPayment).toBeCalledWith(payment.data, 500) + + expect(paymentRepo.save).toBeCalledTimes(1) + expect(paymentRepo.save).toBeCalledWith(expect.objectContaining({ amount_refunded: 500 })) + }) + + it("throw an error using the provider error response", async () => { + const errResponse = { + error: "Error", + code: 400, + detail: "Error details", + } + + const mockPaymentProcessor = new PaymentProcessor(container) + mockPaymentProcessor.refundPayment = jest + .fn() + .mockReturnValue(Promise.resolve(errResponse)) + + container.register( + paymentProcessorResolutionKey, + asValue(mockPaymentProcessor) + ) + + const providerService = container.resolve(paymentServiceResolutionKey) + + const err = await providerService + .refundFromPayment(payment) + .catch((e) => e) + + expect(err.message).toBe( + `${errResponse.error}:${EOL}${errResponse.detail}` + ) + }) + }) +}) diff --git a/packages/medusa/src/services/payment-provider.ts b/packages/medusa/src/services/payment-provider.ts index f7c71f69b3..9ac7843e78 100644 --- a/packages/medusa/src/services/payment-provider.ts +++ b/packages/medusa/src/services/payment-provider.ts @@ -2,8 +2,11 @@ import { isDefined, MedusaError } from "medusa-core-utils" import { BasePaymentService } from "medusa-interfaces" import { EntityManager } from "typeorm" import { + AbstractPaymentProcessor, AbstractPaymentService, + isPaymentProcessorError, PaymentContext, + PaymentProcessorError, PaymentSessionResponse, TransactionBaseService, } from "../interfaces" @@ -26,6 +29,7 @@ import { buildQuery, isString } from "../utils" import { FlagRouter } from "../utils/flag-router" import { CustomerService } from "./index" import PaymentService from "./payment" +import { EOL } from "os" type PaymentProviderKey = `pp_${string}` | "systemPaymentProviderService" type InjectedDependencies = { @@ -53,6 +57,10 @@ export default class PaymentProviderService extends TransactionBaseService { // eslint-disable-next-line max-len protected readonly paymentProviderRepository_: typeof PaymentProviderRepository protected readonly paymentRepository_: typeof PaymentRepository + protected get paymentService_(): PaymentService { + // defer resolution. then it will use the cached resolved service + return this.container_.paymentService + } protected readonly refundRepository_: typeof RefundRepository protected readonly customerService_: CustomerService protected readonly logger_: Logger @@ -98,15 +106,27 @@ export default class PaymentProviderService extends TransactionBaseService { return await ppRepo.find() } + /** + * Retrieve a payment entity with the given id. + * @param paymentId + * @param relations + */ async retrievePayment( - id: string, + paymentId: string, relations: string[] = [] ): Promise { + if (!isDefined(paymentId)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `"paymentId" must be defined` + ) + } + const paymentRepo = this.activeManager_.withRepository( this.paymentRepository_ ) const query = { - where: { id }, + where: { id: paymentId }, relations: [] as string[], } @@ -119,13 +139,18 @@ export default class PaymentProviderService extends TransactionBaseService { if (!payment) { throw new MedusaError( MedusaError.Types.NOT_FOUND, - `Payment with ${id} was not found` + `Payment with ${paymentId} was not found` ) } return payment } + /** + * List all the payments according to the given selector and config. + * @param selector + * @param config + */ async listPayments( selector: Selector, config: FindConfig = { @@ -139,35 +164,54 @@ export default class PaymentProviderService extends TransactionBaseService { return await payRepo.find(query) } + /** + * Return the payment session for the given id. + * @param paymentSessionId + * @param relations + */ async retrieveSession( - id: string, + paymentSessionId: string, relations: string[] = [] ): Promise { + if (!isDefined(paymentSessionId)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `"paymentSessionId" must be defined` + ) + } + const sessionRepo = this.activeManager_.withRepository( this.paymentSessionRepository_ ) - const query = { - where: { id }, - relations: [] as string[], - } - - if (relations.length) { - query.relations = relations - } - + const query = buildQuery({ id: paymentSessionId }, { relations }) const session = await sessionRepo.findOne(query) if (!session) { throw new MedusaError( MedusaError.Types.NOT_FOUND, - `Payment Session with ${id} was not found` + `Payment Session with ${paymentSessionId} was not found` ) } return session } + /** + * @deprecated + * @param providerId + * @param cart + */ + async createSession(providerId: string, cart: Cart): Promise + + /** + * Creates a payment session with the given provider. + * @param sessionInput + */ + async createSession( + sessionInput: PaymentSessionInput + ): Promise + /** * Creates a payment session with the given provider. * @param providerIdOrSessionInput - the id of the provider to create payment with or the input data @@ -184,11 +228,14 @@ export default class PaymentProviderService extends TransactionBaseService { const providerId = isString(providerIdOrSessionInput) ? providerIdOrSessionInput : providerIdOrSessionInput.provider_id + const data = ( isString(providerIdOrSessionInput) ? cart : providerIdOrSessionInput ) as Cart | PaymentSessionInput - const provider = this.retrieveProvider(providerId) + const provider = this.retrieveProvider< + AbstractPaymentService | AbstractPaymentProcessor + >(providerId) const context = this.buildPaymentProcessorContext(data) if (!isDefined(context.currency_code) || !isDefined(context.amount)) { @@ -198,9 +245,28 @@ export default class PaymentProviderService extends TransactionBaseService { ) } - const paymentResponse = await provider - .withTransaction(transactionManager) - .createPayment(context) + let paymentResponse + if (provider instanceof AbstractPaymentProcessor) { + paymentResponse = await provider.initiatePayment({ + amount: context.amount, + context: context.context, + currency_code: context.currency_code, + customer: context.customer, + email: context.email, + billing_address: context.billing_address, + resource_id: context.resource_id, + paymentSessionData: {}, + }) + + if ("error" in paymentResponse) { + this.throwFromPaymentProcessorError(paymentResponse) + } + } else { + // Added to stay backward compatible + paymentResponse = await provider + .withTransaction(transactionManager) + .createPayment(context) + } const sessionData = paymentResponse.session_data ?? paymentResponse @@ -242,10 +308,21 @@ export default class PaymentProviderService extends TransactionBaseService { ): Promise { return this.atomicPhase_(async (transactionManager) => { const session = await this.retrieveSession(paymentSession.id) - const provider = this.retrieveProvider( - paymentSession.provider_id - ) - await provider.withTransaction(transactionManager).deletePayment(session) + + const provider = this.retrieveProvider< + AbstractPaymentService | AbstractPaymentProcessor + >(paymentSession.provider_id) + + if (provider instanceof AbstractPaymentProcessor) { + const error = await provider.deletePayment(session.data) + if (isPaymentProcessorError(error)) { + this.throwFromPaymentProcessorError(error) + } + } else { + await provider + .withTransaction(transactionManager) + .deletePayment(session) + } const sessionRepo = transactionManager.withRepository( this.paymentSessionRepository_ @@ -271,16 +348,42 @@ export default class PaymentProviderService extends TransactionBaseService { sessionInput: Cart | PaymentSessionInput ): Promise { return await this.atomicPhase_(async (transactionManager) => { - const provider = this.retrieveProvider(paymentSession.provider_id) + const provider = this.retrieveProvider< + AbstractPaymentService | AbstractPaymentProcessor + >(paymentSession.provider_id) const context = this.buildPaymentProcessorContext(sessionInput) - const paymentResponse = await provider - .withTransaction(transactionManager) - .updatePayment(paymentSession.data, context) + let paymentResponse + if (provider instanceof AbstractPaymentProcessor) { + paymentResponse = + (await provider.updatePayment({ + amount: context.amount, + context: context.context, + currency_code: context.currency_code, + customer: context.customer, + email: context.email, + billing_address: context.billing_address, + resource_id: context.resource_id, + paymentSessionData: paymentSession.data, + })) ?? {} + + if (paymentResponse && "error" in paymentResponse) { + this.throwFromPaymentProcessorError(paymentResponse) + } + } else { + paymentResponse = await provider + .withTransaction(transactionManager) + .updatePayment(paymentSession.data, context) + } const sessionData = paymentResponse.session_data ?? paymentResponse + // If no update occurs, return the original session + if (!sessionData) { + return await this.retrieveSession(paymentSession.id) + } + await this.processUpdateRequestsData( { customer: { id: context.customer?.id }, @@ -309,10 +412,20 @@ export default class PaymentProviderService extends TransactionBaseService { return } - const provider = this.retrieveProvider(paymentSession.provider_id) - await provider - .withTransaction(transactionManager) - .deletePayment(paymentSession) + const provider = this.retrieveProvider< + AbstractPaymentService | AbstractPaymentProcessor + >(paymentSession.provider_id) + + if (provider instanceof AbstractPaymentProcessor) { + const error = await provider.deletePayment(paymentSession.data) + if (isPaymentProcessorError(error)) { + this.throwFromPaymentProcessorError(error) + } + } else { + await provider + .withTransaction(transactionManager) + .deletePayment(paymentSession) + } const sessionRepo = transactionManager.withRepository( this.paymentSessionRepository_ @@ -324,15 +437,20 @@ export default class PaymentProviderService extends TransactionBaseService { /** * Finds a provider given an id - * @param {string} providerId - the id of the provider to get - * @return {PaymentService} the payment provider + * @param providerId - the id of the provider to get + * @return the payment provider */ retrieveProvider< - TProvider extends AbstractPaymentService | typeof BasePaymentService + TProvider extends + | AbstractPaymentService + | typeof BasePaymentService + | AbstractPaymentProcessor >( providerId: string ): TProvider extends AbstractPaymentService ? AbstractPaymentService + : TProvider extends AbstractPaymentProcessor + ? AbstractPaymentProcessor : typeof BasePaymentService { try { let provider @@ -356,10 +474,25 @@ export default class PaymentProviderService extends TransactionBaseService { const { payment_session, currency_code, amount, provider_id } = data const providerId = provider_id ?? payment_session.provider_id - const provider = this.retrieveProvider(providerId) - const paymentData = await provider - .withTransaction(transactionManager) - .getPaymentData(payment_session) + const provider = this.retrieveProvider< + AbstractPaymentService | AbstractPaymentProcessor + >(providerId) + + let paymentData: Record = {} + + if (provider instanceof AbstractPaymentProcessor) { + const res = await provider.retrievePayment(payment_session.data) + if ("error" in res) { + this.throwFromPaymentProcessorError(res as PaymentProcessorError) + } else { + // Use else to avoid casting the object and infer the type instead + paymentData = res + } + } else { + paymentData = await provider + .withTransaction(transactionManager) + .getPaymentData(payment_session) + } const paymentRepo = transactionManager.withRepository( this.paymentRepository_ @@ -382,8 +515,7 @@ export default class PaymentProviderService extends TransactionBaseService { data: { order_id?: string; swap_id?: string } ): Promise { return await this.atomicPhase_(async (transactionManager) => { - const paymentService = this.container_.paymentService - return await paymentService + return await this.paymentService_ .withTransaction(transactionManager) .update(paymentId, data) }) @@ -403,14 +535,28 @@ export default class PaymentProviderService extends TransactionBaseService { } const provider = this.retrieveProvider(paymentSession.provider_id) - const { status, data } = await provider - .withTransaction(transactionManager) - .authorizePayment(session, context) - session.data = data - session.status = status + if (provider instanceof AbstractPaymentProcessor) { + const res = await provider.authorizePayment( + paymentSession.data, + context + ) + if ("error" in res) { + this.throwFromPaymentProcessorError(res) + } else { + // Use else to avoid casting the object and infer the type instead + session.data = res.data + session.status = res.status + } + } else { + const { status, data } = await provider + .withTransaction(transactionManager) + .authorizePayment(session, context) + session.data = data + session.status = status + } - if (status === PaymentSessionStatus.AUTHORIZED) { + if (session.status === PaymentSessionStatus.AUTHORIZED) { session.payment_authorized_at = new Date() } @@ -430,10 +576,17 @@ export default class PaymentProviderService extends TransactionBaseService { const provider = this.retrieveProvider(paymentSession.provider_id) - session.data = await provider - .withTransaction(transactionManager) - .updatePaymentData(paymentSession.data, data) - session.status = paymentSession.status + if (provider instanceof AbstractPaymentProcessor) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `The payment provider ${paymentSession.provider_id} is of type PaymentProcessor. PaymentProcessors cannot update payment session data.` + ) + } else { + session.data = await provider + .withTransaction(transactionManager) + .updatePaymentData(paymentSession.data, data) + session.status = paymentSession.status + } const sessionRepo = transactionManager.withRepository( this.paymentSessionRepository_ @@ -448,9 +601,17 @@ export default class PaymentProviderService extends TransactionBaseService { return await this.atomicPhase_(async (transactionManager) => { const payment = await this.retrievePayment(paymentObj.id) const provider = this.retrieveProvider(payment.provider_id) - payment.data = await provider - .withTransaction(transactionManager) - .cancelPayment(payment) + + if (provider instanceof AbstractPaymentProcessor) { + const error = await provider.cancelPayment(payment.data) + if (isPaymentProcessorError(error)) { + this.throwFromPaymentProcessorError(error) + } + } else { + payment.data = await provider + .withTransaction(transactionManager) + .cancelPayment(payment) + } const now = new Date() payment.canceled_at = now.toISOString() @@ -464,6 +625,10 @@ export default class PaymentProviderService extends TransactionBaseService { async getStatus(payment: Payment): Promise { const provider = this.retrieveProvider(payment.provider_id) + if (provider instanceof AbstractPaymentProcessor) { + return await provider.getPaymentStatus(payment.data) + } + return await provider .withTransaction(this.activeManager_) .getStatus(payment.data) @@ -475,9 +640,20 @@ export default class PaymentProviderService extends TransactionBaseService { return await this.atomicPhase_(async (transactionManager) => { const payment = await this.retrievePayment(paymentObj.id) const provider = this.retrieveProvider(payment.provider_id) - payment.data = await provider - .withTransaction(transactionManager) - .capturePayment(payment) + + if (provider instanceof AbstractPaymentProcessor) { + const res = await provider.capturePayment(payment.data) + if ("error" in res) { + this.throwFromPaymentProcessorError(res as PaymentProcessorError) + } else { + // Use else to avoid casting the object and infer the type instead + payment.data = res + } + } else { + payment.data = await provider + .withTransaction(transactionManager) + .capturePayment(payment) + } const now = new Date() payment.captured_at = now.toISOString() @@ -536,9 +712,23 @@ export default class PaymentProviderService extends TransactionBaseService { const refundAmount = Math.min(currentRefundable, balance) const provider = this.retrieveProvider(paymentToRefund.provider_id) - paymentToRefund.data = await provider - .withTransaction(transactionManager) - .refundPayment(paymentToRefund, refundAmount) + + if (provider instanceof AbstractPaymentProcessor) { + const res = await provider.refundPayment( + paymentToRefund.data, + refundAmount + ) + if (isPaymentProcessorError(res)) { + this.throwFromPaymentProcessorError(res as PaymentProcessorError) + } else { + // Use else to avoid casting the object and infer the type instead + paymentToRefund.data = res + } + } else { + paymentToRefund.data = await provider + .withTransaction(transactionManager) + .refundPayment(paymentToRefund, refundAmount) + } paymentToRefund.amount_refunded += refundAmount await paymentRepo.save(paymentToRefund) @@ -591,9 +781,20 @@ export default class PaymentProviderService extends TransactionBaseService { } const provider = this.retrieveProvider(payment.provider_id) - payment.data = await provider - .withTransaction(manager) - .refundPayment(payment, amount) + + if (provider instanceof AbstractPaymentProcessor) { + const res = await provider.refundPayment(payment.data, amount) + if (isPaymentProcessorError(res)) { + this.throwFromPaymentProcessorError(res as PaymentProcessorError) + } else { + // Use else to avoid casting the object and infer the type instead + payment.data = res + } + } else { + payment.data = await provider + .withTransaction(manager) + .refundPayment(payment, amount) + } payment.amount_refunded += amount @@ -652,19 +853,21 @@ export default class PaymentProviderService extends TransactionBaseService { context.cart = { context: cart.context, shipping_address: cart.shipping_address, + billing_address: cart.billing_address, id: cart.id, email: cart.email, shipping_methods: cart.shipping_methods, } context.amount = cart.total! context.currency_code = cart.region?.currency_code + context.resource_id = cart.id Object.assign(context, cart) } else { const data = cartOrData as PaymentSessionInput context.cart = data.cart context.amount = data.amount context.currency_code = data.currency_code - context.resource_id = data.resource_id + context.resource_id = data.resource_id ?? data.cart.id Object.assign(context, cart) } @@ -743,4 +946,12 @@ export default class PaymentProviderService extends TransactionBaseService { }) } } + + private throwFromPaymentProcessorError(errObj: PaymentProcessorError) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `${errObj.error}${errObj.detail ? `:${EOL}${errObj.detail}` : ""}`, + errObj.code + ) + } } diff --git a/packages/medusa/src/types/payment.ts b/packages/medusa/src/types/payment.ts index 6cbb970bd1..299f97b124 100644 --- a/packages/medusa/src/types/payment.ts +++ b/packages/medusa/src/types/payment.ts @@ -18,11 +18,13 @@ export type PaymentSessionInput = { email: string shipping_address: Address | null shipping_methods: ShippingMethod[] + billing_address?: Address | null } customer?: Customer | null currency_code: string amount: number resource_id?: string + paymentSessionData?: Record } export type CreatePaymentInput = {