Files
medusa-store/packages/medusa/src/services/payment-provider.ts

957 lines
28 KiB
TypeScript

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"
import {
Cart,
Payment,
PaymentProvider,
PaymentSession,
PaymentSessionStatus,
Refund,
} from "../models"
import { PaymentRepository } from "../repositories/payment"
import { PaymentProviderRepository } from "../repositories/payment-provider"
import { PaymentSessionRepository } from "../repositories/payment-session"
import { RefundRepository } from "../repositories/refund"
import { FindConfig, Selector } from "../types/common"
import { Logger } from "../types/global"
import { CreatePaymentInput, PaymentSessionInput } from "../types/payment"
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 = {
manager: EntityManager
paymentSessionRepository: typeof PaymentSessionRepository
paymentProviderRepository: typeof PaymentProviderRepository
paymentRepository: typeof PaymentRepository
refundRepository: typeof RefundRepository
paymentService: PaymentService
customerService: CustomerService
featureFlagRouter: FlagRouter
logger: Logger
} & {
[key in `${PaymentProviderKey}`]:
| AbstractPaymentService
| typeof BasePaymentService
}
/**
* Helps retrieve payment providers
*/
export default class PaymentProviderService extends TransactionBaseService {
protected readonly container_: InjectedDependencies
protected readonly paymentSessionRepository_: typeof PaymentSessionRepository
// 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
protected readonly featureFlagRouter_: FlagRouter
constructor(container: InjectedDependencies) {
super(container)
this.container_ = container
this.paymentSessionRepository_ = container.paymentSessionRepository
this.paymentProviderRepository_ = container.paymentProviderRepository
this.paymentRepository_ = container.paymentRepository
this.refundRepository_ = container.refundRepository
this.customerService_ = container.customerService
this.featureFlagRouter_ = container.featureFlagRouter
this.logger_ = container.logger
}
async registerInstalledProviders(providerIds: string[]): Promise<void> {
return await this.atomicPhase_(async (transactionManager) => {
const model = transactionManager.withRepository(
this.paymentProviderRepository_
)
await model.update({}, { is_installed: false })
await Promise.all(
providerIds.map(async (providerId) => {
const provider = model.create({
id: providerId,
is_installed: true,
})
return await model.save(provider)
})
)
})
}
async list(): Promise<PaymentProvider[]> {
const ppRepo = this.activeManager_.withRepository(
this.paymentProviderRepository_
)
return await ppRepo.find()
}
/**
* Retrieve a payment entity with the given id.
* @param paymentId
* @param relations
*/
async retrievePayment(
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: paymentId },
relations: [] as string[],
}
if (relations.length) {
query.relations = relations
}
const payment = await paymentRepo.findOne(query)
if (!payment) {
throw new MedusaError(
MedusaError.Types.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> = {
skip: 0,
take: 50,
order: { created_at: "DESC" },
}
): Promise<Payment[]> {
const payRepo = this.activeManager_.withRepository(this.paymentRepository_)
const query = buildQuery(selector, config)
return await payRepo.find(query)
}
/**
* Return the payment session for the given id.
* @param paymentSessionId
* @param relations
*/
async retrieveSession(
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 = buildQuery({ id: paymentSessionId }, { relations })
const session = await sessionRepo.findOne(query)
if (!session) {
throw new MedusaError(
MedusaError.Types.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
* @param cart - a cart object used to calculate the amount, etc. from
* @return the payment session
*/
async createSession<
TInput extends string | PaymentSessionInput = string | PaymentSessionInput
>(
providerIdOrSessionInput: TInput,
...[cart]: TInput extends string ? [Cart] : [never?]
): Promise<PaymentSession> {
return await this.atomicPhase_(async (transactionManager) => {
const providerId = isString(providerIdOrSessionInput)
? providerIdOrSessionInput
: providerIdOrSessionInput.provider_id
const data = (
isString(providerIdOrSessionInput) ? cart : providerIdOrSessionInput
) as Cart | PaymentSessionInput
const provider = this.retrieveProvider<
AbstractPaymentService | AbstractPaymentProcessor
>(providerId)
const context = this.buildPaymentProcessorContext(data)
if (!isDefined(context.currency_code) || !isDefined(context.amount)) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"`currency_code` and `amount` are required to create payment session."
)
}
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
await this.processUpdateRequestsData(
{
customer: { id: context.customer?.id },
},
paymentResponse
)
return await this.saveSession(providerId, {
payment_session_id: !isString(providerIdOrSessionInput)
? providerIdOrSessionInput.payment_session_id
: undefined,
cartId: context.id,
sessionData,
status: PaymentSessionStatus.PENDING,
isInitiated: true,
amount: context.amount,
})
})
}
/**
* Refreshes a payment session with the given provider.
* This means, that we delete the current one and create a new.
* @param paymentSession - the payment session object to
* update
* @param sessionInput
* @return the payment session
*/
async refreshSession(
paymentSession: {
id: string
data: Record<string, unknown>
provider_id: string
},
sessionInput: PaymentSessionInput
): Promise<PaymentSession> {
return this.atomicPhase_(async (transactionManager) => {
const session = await this.retrieveSession(paymentSession.id)
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_
)
await sessionRepo.remove(session)
return await this.createSession(sessionInput)
})
}
/**
* Update a payment session with the given provider.
* @param paymentSession - The paymentSession to update
* @param sessionInput
* @return the payment session
*/
async updateSession(
paymentSession: {
id: string
data: Record<string, unknown>
provider_id: string
},
sessionInput: Cart | PaymentSessionInput
): Promise<PaymentSession> {
return await this.atomicPhase_(async (transactionManager) => {
const provider = this.retrieveProvider<
AbstractPaymentService | AbstractPaymentProcessor
>(paymentSession.provider_id)
const context = this.buildPaymentProcessorContext(sessionInput)
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 },
},
paymentResponse
)
return await this.saveSession(paymentSession.provider_id, {
payment_session_id: paymentSession.id,
sessionData,
isInitiated: true,
amount: context.amount,
})
})
}
async deleteSession(
paymentSession: PaymentSession
): Promise<PaymentSession | undefined> {
return await this.atomicPhase_(async (transactionManager) => {
const session = await this.retrieveSession(paymentSession.id).catch(
() => void 0
)
if (!session) {
return
}
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_
)
return await sessionRepo.remove(session)
})
}
/**
* Finds a provider given an id
* @param providerId - the id of the provider to get
* @return the payment provider
*/
retrieveProvider<
TProvider extends
| AbstractPaymentService
| typeof BasePaymentService
| AbstractPaymentProcessor
>(
providerId: string
): TProvider extends AbstractPaymentService
? AbstractPaymentService
: TProvider extends AbstractPaymentProcessor
? AbstractPaymentProcessor
: typeof BasePaymentService {
try {
let provider
if (providerId === "system") {
provider = this.container_[`systemPaymentProviderService`]
} else {
provider = this.container_[`pp_${providerId}`]
}
return provider
} catch (err) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Could not find a payment provider with id: ${providerId}`
)
}
}
async createPayment(data: CreatePaymentInput): Promise<Payment> {
return await this.atomicPhase_(async (transactionManager) => {
const { payment_session, currency_code, amount, provider_id } = data
const providerId = provider_id ?? payment_session.provider_id
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_
)
const created = paymentRepo.create({
provider_id: providerId,
amount,
currency_code,
data: paymentData,
cart_id: data.cart_id,
})
return await paymentRepo.save(created)
})
}
async updatePayment(
paymentId: string,
data: { order_id?: string; swap_id?: string }
): Promise<Payment> {
return await this.atomicPhase_(async (transactionManager) => {
return await this.paymentService_
.withTransaction(transactionManager)
.update(paymentId, data)
})
}
async authorizePayment(
paymentSession: PaymentSession,
context: Record<string, unknown>
): Promise<PaymentSession | undefined> {
return await this.atomicPhase_(async (transactionManager) => {
const session = await this.retrieveSession(paymentSession.id).catch(
() => void 0
)
if (!session) {
return
}
const provider = this.retrieveProvider(paymentSession.provider_id)
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 (session.status === PaymentSessionStatus.AUTHORIZED) {
session.payment_authorized_at = new Date()
}
const sessionRepo = transactionManager.withRepository(
this.paymentSessionRepository_
)
return await sessionRepo.save(session)
})
}
async updateSessionData(
paymentSession: PaymentSession,
data: Record<string, unknown>
): Promise<PaymentSession> {
return await this.atomicPhase_(async (transactionManager) => {
const session = await this.retrieveSession(paymentSession.id)
const provider = this.retrieveProvider(paymentSession.provider_id)
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_
)
return await sessionRepo.save(session)
})
}
async cancelPayment(
paymentObj: Partial<Payment> & { id: string }
): Promise<Payment> {
return await this.atomicPhase_(async (transactionManager) => {
const payment = await this.retrievePayment(paymentObj.id)
const provider = this.retrieveProvider(payment.provider_id)
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()
const paymentRepo = transactionManager.withRepository(
this.paymentRepository_
)
return await paymentRepo.save(payment)
})
}
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)
}
async capturePayment(
paymentObj: Partial<Payment> & { id: string }
): Promise<Payment> {
return await this.atomicPhase_(async (transactionManager) => {
const payment = await this.retrievePayment(paymentObj.id)
const provider = this.retrieveProvider(payment.provider_id)
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()
const paymentRepo = transactionManager.withRepository(
this.paymentRepository_
)
return await paymentRepo.save(payment)
})
}
async refundPayment(
payObjs: Payment[],
amount: number,
reason: string,
note?: string
): Promise<Refund> {
return await this.atomicPhase_(async (transactionManager) => {
const payments = await this.listPayments({
id: payObjs.map((p) => p.id),
})
let order_id!: string
const refundable = payments.reduce((acc, next) => {
order_id = next.order_id
if (next.captured_at) {
return (acc += next.amount - next.amount_refunded)
}
return acc
}, 0)
if (refundable < amount) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Refund amount is greater that the refundable amount"
)
}
let balance = amount
const used: string[] = []
const paymentRepo = transactionManager.withRepository(
this.paymentRepository_
)
let paymentToRefund = payments.find(
(payment) => payment.amount - payment.amount_refunded > 0
)
while (paymentToRefund) {
const currentRefundable =
paymentToRefund.amount - paymentToRefund.amount_refunded
const refundAmount = Math.min(currentRefundable, balance)
const provider = this.retrieveProvider(paymentToRefund.provider_id)
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)
balance -= refundAmount
used.push(paymentToRefund.id)
if (balance > 0) {
paymentToRefund = payments.find(
(payment) =>
payment.amount - payment.amount_refunded > 0 &&
!used.includes(payment.id)
)
} else {
paymentToRefund = undefined
}
}
const refundRepo = transactionManager.withRepository(
this.refundRepository_
)
const toCreate = {
order_id,
amount,
reason,
note,
}
const created = refundRepo.create(toCreate)
return await refundRepo.save(created)
})
}
async refundFromPayment(
payment: Payment,
amount: number,
reason: string,
note?: string
): Promise<Refund> {
return await this.atomicPhase_(async (manager) => {
const refundable = payment.amount - payment.amount_refunded
if (refundable < amount) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Refund amount is greater that the refundable amount"
)
}
const provider = this.retrieveProvider(payment.provider_id)
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
const paymentRepo = manager.withRepository(this.paymentRepository_)
await paymentRepo.save(payment)
const refundRepo = manager.withRepository(this.refundRepository_)
const toCreate = {
payment_id: payment.id,
amount,
reason,
note,
}
const created = refundRepo.create(toCreate)
return await refundRepo.save(created)
})
}
async retrieveRefund(
id: string,
config: FindConfig<Refund> = {}
): Promise<Refund | never> {
const refRepo = this.activeManager_.withRepository(this.refundRepository_)
const query = buildQuery({ id }, config)
const refund = await refRepo.findOne(query)
if (!refund) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`A refund with ${id} was not found`
)
}
return refund
}
/**
* Build the create session context for both legacy and new API
* @param cartOrData
* @protected
*/
protected buildPaymentProcessorContext(
cartOrData: Cart | PaymentSessionInput
): Cart & PaymentContext {
const cart =
"object" in cartOrData && cartOrData.object === "cart"
? cartOrData
: ((cartOrData as PaymentSessionInput).cart as Cart)
const context = {} as Cart & PaymentContext
// TODO: only to support legacy API. Once we are ready to break the API, the cartOrData will only support PaymentSessionInput
if ("object" in cartOrData && cartOrData.object === "cart") {
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 ?? data.cart.id
Object.assign(context, cart)
}
return context
}
/**
* Create or update a Payment session data.
* @param providerId
* @param data
* @protected
*/
protected async saveSession(
providerId: string,
data: {
payment_session_id?: string
cartId?: string
amount?: number
sessionData: Record<string, unknown>
isSelected?: boolean
isInitiated?: boolean
status?: PaymentSessionStatus
}
): Promise<PaymentSession> {
const sessionRepo = this.activeManager_.withRepository(
this.paymentSessionRepository_
)
// Update an existing session
if (data.payment_session_id) {
const session = await this.retrieveSession(data.payment_session_id)
session.data = data.sessionData ?? session.data
session.status = data.status ?? session.status
session.amount = data.amount ?? session.amount
session.is_initiated = data.isInitiated ?? session.is_initiated
session.is_selected = data.isSelected ?? session.is_selected
return await sessionRepo.save(session)
}
// Create a new session
const toCreate: Partial<PaymentSession> = {
cart_id: data.cartId || null,
provider_id: providerId,
data: data.sessionData,
is_selected: data.isSelected,
is_initiated: data.isInitiated,
status: data.status,
amount: data.amount,
}
const created = sessionRepo.create(toCreate)
return await sessionRepo.save(created)
}
/**
* Process the collected data. Can be used every time we need to process some collected data returned by the provider
* @param data
* @param paymentResponse
* @protected
*/
protected async processUpdateRequestsData(
data: { customer?: { id?: string } } = {},
paymentResponse: PaymentSessionResponse | Record<string, unknown>
): Promise<void> {
const { update_requests } = paymentResponse as PaymentSessionResponse
if (!update_requests) {
return
}
if (update_requests.customer_metadata && data.customer?.id) {
await this.customerService_
.withTransaction(this.activeManager_)
.update(data.customer.id, {
metadata: update_requests.customer_metadata,
})
}
}
private throwFromPaymentProcessorError(errObj: PaymentProcessorError) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`${errObj.error}${errObj.detail ? `:${EOL}${errObj.detail}` : ""}`,
errObj.code
)
}
}