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>
This commit is contained in:
Adrien de Peretti
2023-02-21 09:48:32 +01:00
committed by GitHub
parent 1c40346e9e
commit f43e9f0f20
13 changed files with 1714 additions and 533 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -6,21 +6,21 @@ export type PaymentProcessorContext = {
email: string
currency_code: string
amount: number
resource_id?: string
resource_id: string
customer?: Customer
context: Record<string, unknown>
paymentSessionData: Record<string, unknown>
}
export type PaymentProcessorSessionResponse = {
update_requests: { customer_metadata: Record<string, unknown> }
update_requests?: { customer_metadata?: Record<string, unknown> }
session_data: Record<string, unknown>
}
export interface PaymentProcessorError {
error: string
code: number
details: any
code?: string
detail?: any
}
/**
@@ -51,42 +51,60 @@ export interface PaymentProcessor {
*/
updatePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | void>
): Promise<PaymentProcessorError | PaymentProcessorSessionResponse | void>
/**
* Refund an existing session
* @param context
* @param paymentSessionData
* @param refundAmount
*/
refundPayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>,
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<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>,
context: Record<string, unknown>
): Promise<
| PaymentProcessorError
| {
status: PaymentSessionStatus
data: PaymentProcessorSessionResponse["session_data"]
}
>
/**
* Capture an existing session
* @param context
* @param paymentSessionData
*/
capturePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
/**
* Delete an existing session
*/
deletePayment(paymentId: string): Promise<PaymentProcessorError | void>
deletePayment(
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
/**
* Retrieve an existing session
*/
retrievePayment(
paymentId: string
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
@@ -94,12 +112,18 @@ export interface PaymentProcessor {
/**
* Cancel an existing session
*/
cancelPayment(paymentId: string): Promise<PaymentProcessorError | void>
cancelPayment(
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
/**
* Return the status of the session
*/
getPaymentStatus(paymentId: string): Promise<PaymentSessionStatus>
getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<PaymentSessionStatus>
}
/**
@@ -111,7 +135,7 @@ export abstract class AbstractPaymentProcessor implements PaymentProcessor {
protected readonly config?: Record<string, unknown> // 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<void>
abstract capturePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
abstract authorizePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>,
context: Record<string, unknown>
): Promise<
| PaymentProcessorError
| {
status: PaymentSessionStatus
data: PaymentProcessorSessionResponse["session_data"]
}
>
abstract cancelPayment(
paymentId: string
): Promise<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
abstract initiatePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | PaymentProcessorSessionResponse>
abstract deletePayment(
paymentId: string
): Promise<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
abstract getPaymentStatus(paymentId: string): Promise<PaymentSessionStatus>
abstract getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<PaymentSessionStatus>
abstract refundPayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | void>
paymentSessionData: Record<string, unknown>,
refundAmount: number
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
abstract retrievePayment(
paymentId: string
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
>
abstract updatePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | void>
): Promise<PaymentProcessorError | PaymentProcessorSessionResponse | void>
}
/**
@@ -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)
}

View File

@@ -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<string, unknown>
}
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) {

View File

@@ -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(),
}),
})

View File

@@ -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 = <T>(
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>(
"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<typeof BaseNotificationService[]>(
container,
"notificationProviders",
logger
) || []
const notiIds = notiProviders.map((p) => p.getIdentifier())
const nProviderService = container.resolve<NotificationService>(
"notificationService"
)
await nProviderService.registerInstalledProviders(notiIds)
const fulfilProviders =
silentResolution<typeof BaseFulfillmentService[]>(
container,
"fulfillmentProviders",
logger
) || []
const fulfilIds = fulfilProviders.map((p) => p.getIdentifier())
const fProviderService = container.resolve<FulfillmentProviderService>(
"fulfillmentProviderService"
)
await fProviderService.registerInstalledProviders(fulfilIds)
const taxProviders =
silentResolution<AbstractTaxService[]>(
container,
"taxProviders",
logger
) || []
const taxIds = taxProviders.map((p) => p.getIdentifier())
const tProviderService =
container.resolve<TaxProviderService>("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<void> {
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>(
"paymentProviderService"
)
await pProviderService
.withTransaction(manager)
.registerInstalledProviders(payIds)
}
async function registerPaymentProcessor({
manager,
container,
logger,
}: {
container: AwilixContainer
manager: EntityManager
logger: Logger
}): Promise<void> {
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>(
"paymentProviderService"
)
await pProviderService
.withTransaction(manager)
.registerInstalledProviders(payIds)
}
async function registerNotificationProvider({
manager,
container,
logger,
}: {
container: AwilixContainer
manager: EntityManager
logger: Logger
}): Promise<void> {
const notiProviders =
silentResolution<typeof BaseNotificationService[]>(
container,
"notificationProviders",
logger
) || []
const notiIds = notiProviders.map((p) => p.getIdentifier())
const nProviderService = container.resolve<NotificationService>(
"notificationService"
)
await nProviderService
.withTransaction(manager)
.registerInstalledProviders(notiIds)
}
async function registerFulfillmentProvider({
manager,
container,
logger,
}: {
container: AwilixContainer
manager: EntityManager
logger: Logger
}): Promise<void> {
const fulfilProviders =
silentResolution<typeof BaseFulfillmentService[]>(
container,
"fulfillmentProviders",
logger
) || []
const fulfilIds = fulfilProviders.map((p) => p.getIdentifier())
const fProviderService = container.resolve<FulfillmentProviderService>(
"fulfillmentProviderService"
)
await fProviderService
.withTransaction(manager)
.registerInstalledProviders(fulfilIds)
}
async function registerTaxProvider({
manager,
container,
logger,
}: {
container: AwilixContainer
manager: EntityManager
logger: Logger
}): Promise<void> {
const taxProviders =
silentResolution<AbstractTaxService[]>(container, "taxProviders", logger) ||
[]
const taxIds = taxProviders.map((p) => p.getIdentifier())
const tProviderService =
container.resolve<TaxProviderService>("taxProviderService")
await tProviderService
.withTransaction(manager)
.registerInstalledProviders(taxIds)
}

View File

@@ -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<string, unknown>
registrationName: string
}
export function registerPaymentServiceFromClass(
klass: ClassConstructor<AbstractPaymentService>,
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<AbstractPaymentProcessor>,
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),
})
}

View File

@@ -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 =

View File

@@ -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<string, unknown>): Promise<PaymentSessionStatus> {
return Promise.resolve(PaymentSessionStatus.PENDING);
}
init(): Promise<void> {
return Promise.resolve(undefined);
}
initiatePayment(context: PaymentProcessorContext): Promise<PaymentProcessorError | PaymentProcessorSessionResponse> {
return Promise.resolve({ } as PaymentProcessorSessionResponse);
}
retrievePayment(paymentSessionData: Record<string, unknown>): Promise<PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]> {
return Promise.resolve({ });
}
updatePayment(context: PaymentProcessorContext): Promise<PaymentProcessorError | void> {
return Promise.resolve(undefined);
}
capturePayment(paymentSessionData: Record<string, unknown>): Promise<PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]> {
return Promise.resolve({ });
}
refundPayment(paymentSessionData: Record<string, unknown>): Promise<PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]> {
return Promise.resolve({});
}
cancelPayment(paymentSessionData: Record<string, unknown>): Promise<PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]> {
return Promise.resolve({});
}
deletePayment(paymentSessionData: Record<string, unknown>): Promise<PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]> {
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,
}

View File

@@ -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)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -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<Payment | never> {
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<Payment>,
config: FindConfig<Payment> = {
@@ -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<PaymentSession | never> {
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<PaymentSession>
/**
* Creates a payment session with the given provider.
* @param sessionInput
*/
async createSession(
sessionInput: PaymentSessionInput
): Promise<PaymentSession>
/**
* 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<AbstractPaymentService>(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<PaymentSession> {
return this.atomicPhase_(async (transactionManager) => {
const session = await this.retrieveSession(paymentSession.id)
const provider = this.retrieveProvider<AbstractPaymentService>(
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<PaymentSession> {
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<AbstractPaymentService>(providerId)
const paymentData = await provider
.withTransaction(transactionManager)
.getPaymentData(payment_session)
const provider = this.retrieveProvider<
AbstractPaymentService | AbstractPaymentProcessor
>(providerId)
let paymentData: Record<string, unknown> = {}
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<Payment> {
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<PaymentSessionStatus> {
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
)
}
}

View File

@@ -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<string, unknown>
}
export type CreatePaymentInput = {