Files
medusa-store/packages/payment/src/services/payment-module.ts
Oli Juhl 84208aafc1 feat: Create payment sessions (#6549)
~~Opening a draft PR to discuss a couple of implementation details that we should align on~~

**What**

Add workflow and API endpoint for creating payment sessions for a payment collection. Endpoint is currently `POST /store/payment-collection/:id/payment-sessions`. I suggested an alternative in a comment below.

Please note, we intentionally do not want to support creating payment sessions in bulk, as this would become a mess when having to manage multiple calls to third-party providers.
2024-03-05 08:40:47 +00:00

615 lines
17 KiB
TypeScript

import {
CaptureDTO,
Context,
CreateCaptureDTO,
CreatePaymentCollectionDTO,
CreatePaymentProviderDTO,
CreatePaymentSessionDTO,
CreateRefundDTO,
DAL,
InternalModuleDeclaration,
IPaymentModuleService,
ModuleJoinerConfig,
ModulesSdkTypes,
PaymentCollectionDTO,
PaymentDTO,
PaymentSessionDTO,
PaymentSessionStatus,
ProviderWebhookPayload,
RefundDTO,
UpdatePaymentCollectionDTO,
UpdatePaymentDTO,
UpdatePaymentSessionDTO,
} from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
MedusaError,
ModulesSdkUtils,
PaymentActions,
} from "@medusajs/utils"
import {
Capture,
Payment,
PaymentCollection,
PaymentSession,
Refund,
} from "@models"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import PaymentProviderService from "./payment-provider"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
paymentService: ModulesSdkTypes.InternalModuleService<any>
captureService: ModulesSdkTypes.InternalModuleService<any>
refundService: ModulesSdkTypes.InternalModuleService<any>
paymentSessionService: ModulesSdkTypes.InternalModuleService<any>
paymentCollectionService: ModulesSdkTypes.InternalModuleService<any>
paymentProviderService: PaymentProviderService
}
const generateMethodForModels = [PaymentCollection, Payment, PaymentSession]
export default class PaymentModuleService<
TPaymentCollection extends PaymentCollection = PaymentCollection,
TPayment extends Payment = Payment,
TCapture extends Capture = Capture,
TRefund extends Refund = Refund,
TPaymentSession extends PaymentSession = PaymentSession
>
extends ModulesSdkUtils.abstractModuleServiceFactory<
InjectedDependencies,
PaymentCollectionDTO,
{
PaymentCollection: { dto: PaymentCollectionDTO }
PaymentSession: { dto: PaymentSessionDTO }
Payment: { dto: PaymentDTO }
Capture: { dto: CaptureDTO }
Refund: { dto: RefundDTO }
}
>(PaymentCollection, generateMethodForModels, entityNameToLinkableKeysMap)
implements IPaymentModuleService
{
protected baseRepository_: DAL.RepositoryService
protected paymentService_: ModulesSdkTypes.InternalModuleService<TPayment>
protected captureService_: ModulesSdkTypes.InternalModuleService<TCapture>
protected refundService_: ModulesSdkTypes.InternalModuleService<TRefund>
protected paymentSessionService_: ModulesSdkTypes.InternalModuleService<TPaymentSession>
protected paymentCollectionService_: ModulesSdkTypes.InternalModuleService<TPaymentCollection>
protected paymentProviderService_: PaymentProviderService
constructor(
{
baseRepository,
paymentService,
captureService,
refundService,
paymentSessionService,
paymentProviderService,
paymentCollectionService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
super(...arguments)
this.baseRepository_ = baseRepository
this.refundService_ = refundService
this.captureService_ = captureService
this.paymentService_ = paymentService
this.paymentSessionService_ = paymentSessionService
this.paymentProviderService_ = paymentProviderService
this.paymentCollectionService_ = paymentCollectionService
}
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig
}
createPaymentCollections(
data: CreatePaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
createPaymentCollections(
data: CreatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
@InjectTransactionManager("baseRepository_")
async createPaymentCollections(
data: CreatePaymentCollectionDTO | CreatePaymentCollectionDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const collections = await this.paymentCollectionService_.create(
input,
sharedContext
)
return await this.baseRepository_.serialize<PaymentCollectionDTO[]>(
Array.isArray(data) ? collections : collections[0],
{
populate: true,
}
)
}
updatePaymentCollections(
data: UpdatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
updatePaymentCollections(
data: UpdatePaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
@InjectTransactionManager("baseRepository_")
async updatePaymentCollections(
data: UpdatePaymentCollectionDTO | UpdatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const result = await this.paymentCollectionService_.update(
input,
sharedContext
)
return await this.baseRepository_.serialize<PaymentCollectionDTO[]>(
Array.isArray(data) ? result : result[0],
{
populate: true,
}
)
}
completePaymentCollections(
paymentCollectionId: string,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
completePaymentCollections(
paymentCollectionId: string[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
@InjectTransactionManager("baseRepository_")
async completePaymentCollections(
paymentCollectionId: string | string[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
const input = Array.isArray(paymentCollectionId)
? paymentCollectionId.map((id) => ({
id,
completed_at: new Date(),
}))
: [{ id: paymentCollectionId, completed_at: new Date() }]
// TODO: what checks should be done here? e.g. captured_amount === amount?
const updated = await this.paymentCollectionService_.update(
input,
sharedContext
)
return await this.baseRepository_.serialize(
Array.isArray(paymentCollectionId) ? updated : updated[0],
{ populate: true }
)
}
@InjectManager("baseRepository_")
async createPaymentSession(
paymentCollectionId: string,
input: CreatePaymentSessionDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentSessionDTO> {
let paymentSession: PaymentSession
try {
const providerSessionSession =
await this.paymentProviderService_.createSession(input.provider_id, {
context: input.context ?? {},
amount: input.amount,
currency_code: input.currency_code,
})
input.data = {
...input.data,
...providerSessionSession,
}
paymentSession = await this.createPaymentSession_(
paymentCollectionId,
input,
sharedContext
)
} catch (error) {
// In case the session is created at the provider, but fails to be created in Medusa,
// we catch the error and delete the session at the provider and rethrow.
await this.paymentProviderService_.deleteSession({
provider_id: input.provider_id,
data: input.data,
})
throw error
}
return await this.baseRepository_.serialize(paymentSession, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async createPaymentSession_(
paymentCollectionId: string,
data: CreatePaymentSessionDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentSession> {
const paymentSession = await this.paymentSessionService_.create(
{
payment_collection_id: paymentCollectionId,
provider_id: data.provider_id,
amount: data.amount,
currency_code: data.currency_code,
context: data.context,
data: data.data,
},
sharedContext
)
return paymentSession
}
@InjectTransactionManager("baseRepository_")
async updatePaymentSession(
data: UpdatePaymentSessionDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentSessionDTO> {
const session = await this.paymentSessionService_.retrieve(
data.id,
{ select: ["id", "data", "provider_id"] },
sharedContext
)
const updated = await this.paymentSessionService_.update(
{
id: session.id,
amount: data.amount,
currency_code: data.currency_code,
data: data.data,
},
sharedContext
)
return await this.baseRepository_.serialize(updated[0], { populate: true })
}
@InjectTransactionManager("baseRepository_")
async deletePaymentSession(
id: string,
@MedusaContext() sharedContext?: Context
): Promise<void> {
const session = await this.paymentSessionService_.retrieve(
id,
{ select: ["id", "data", "provider_id"] },
sharedContext
)
await this.paymentProviderService_.deleteSession({
provider_id: session.provider_id,
data: session.data,
})
await this.paymentSessionService_.delete(id, sharedContext)
}
@InjectTransactionManager("baseRepository_")
async authorizePaymentSession(
id: string,
context: Record<string, unknown>,
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO> {
const session = await this.paymentSessionService_.retrieve(
id,
{
select: [
"id",
"data",
"provider_id",
"amount",
"currency_code",
"payment_collection_id",
],
},
sharedContext
)
// this method needs to be idempotent
if (session.authorized_at) {
const payment = await this.paymentService_.retrieve(
{ session_id: session.id },
{ relations: ["payment_collection"] },
sharedContext
)
return await this.baseRepository_.serialize(payment, { populate: true })
}
const { data, status } =
await this.paymentProviderService_.authorizePayment(
{
provider_id: session.provider_id,
data: session.data,
},
context
)
await this.paymentSessionService_.update(
{
id: session.id,
data,
status,
authorized_at:
status === PaymentSessionStatus.AUTHORIZED ? new Date() : null,
},
sharedContext
)
if (status !== PaymentSessionStatus.AUTHORIZED) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Session: ${session.id} is not authorized with the provider.`
)
}
// TODO: update status on payment collection if authorized_amount === amount - depends on the BigNumber PR
const payment = await this.paymentService_.create(
{
amount: session.amount,
currency_code: session.currency_code,
payment_session: session.id,
payment_collection_id: session.payment_collection_id,
provider_id: session.provider_id,
// customer_id: context.customer.id,
data,
},
sharedContext
)
return await this.retrievePayment(
payment.id,
{ relations: ["payment_collection"] },
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
async updatePayment(
data: UpdatePaymentDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO> {
// NOTE: currently there is no update with the provider but maybe data could be updated
const result = await this.paymentService_.update(data, sharedContext)
return await this.baseRepository_.serialize<PaymentDTO>(result[0], {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async capturePayment(
data: CreateCaptureDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<PaymentDTO> {
const payment = await this.paymentService_.retrieve(
data.payment_id,
{ select: ["id", "data", "provider_id"] },
sharedContext
)
if (payment.canceled_at) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The payment: ${payment.id} has been canceled.`
)
}
// this method needs to be idempotent
if (payment.captured_at) {
return this.retrievePayment(
data.payment_id,
{ relations: ["captures"] },
sharedContext
)
}
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged
// if (payment.captured_amount + input.amount > payment.amount) {
// throw new MedusaError(
// MedusaError.Types.INVALID_DATA,
// `Total captured amount for payment: ${payment.id} exceeds authorized amount.`
// )
// }
const paymentData = await this.paymentProviderService_.capturePayment({
data: payment.data!,
provider_id: payment.provider_id,
})
await this.captureService_.create(
{
payment: data.payment_id,
amount: data.amount,
captured_by: data.captured_by,
},
sharedContext
)
await this.paymentService_.update(
{ id: payment.id, data: paymentData },
sharedContext
)
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged
// if (payment.captured_amount + data.amount === payment.amount) {
// await this.paymentService_.update(
// { id: payment.id, captured_at: new Date() },
// sharedContext
// )
// }
return await this.retrievePayment(
payment.id,
{ relations: ["captures"] },
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
async refundPayment(
data: CreateRefundDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO> {
const payment = await this.paymentService_.retrieve(
data.payment_id,
{ select: ["id", "data", "provider_id"] },
sharedContext
)
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged
// if (payment.captured_amount < input.amount) {
// throw new MedusaError(
// MedusaError.Types.INVALID_DATA,
// `Refund amount for payment: ${payment.id} cannot be greater than the amount captured on the payment.`
// )
// }
const paymentData = await this.paymentProviderService_.refundPayment(
{
data: payment.data!,
provider_id: payment.provider_id,
},
data.amount
)
await this.refundService_.create(
{
payment: data.payment_id,
amount: data.amount,
created_by: data.created_by,
},
sharedContext
)
await this.paymentService_.update(
{ id: payment.id, data: paymentData },
sharedContext
)
return await this.retrievePayment(
payment.id,
{ relations: ["refunds"] },
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
async cancelPayment(
paymentId: string,
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO> {
const payment = await this.paymentService_.retrieve(
paymentId,
{ select: ["id", "data", "provider_id"] },
sharedContext
)
// TODO: revisit when totals are implemented
// if (payment.captured_amount !== 0) {
// throw new MedusaError(
// MedusaError.Types.INVALID_DATA,
// `Cannot cancel a payment: ${payment.id} that has been captured.`
// )
// }
await this.paymentProviderService_.cancelPayment({
data: payment.data!,
provider_id: payment.provider_id,
})
await this.paymentService_.update(
{ id: paymentId, canceled_at: new Date() },
sharedContext
)
return await this.retrievePayment(payment.id, {}, sharedContext)
}
@InjectTransactionManager("baseRepository_")
async processEvent(
eventData: ProviderWebhookPayload,
@MedusaContext() sharedContext?: Context
): Promise<void> {
const providerId = `pp_${eventData.provider}`
const event = await this.paymentProviderService_.getWebhookActionAndData(
providerId,
eventData.payload
)
if (event.action === PaymentActions.NOT_SUPPORTED) {
return
}
switch (event.action) {
case PaymentActions.SUCCESSFUL: {
const [payment] = await this.listPayments(
{
session_id: event.data.resource_id,
},
{},
sharedContext
)
await this.capturePayment(
{ payment_id: payment.id, amount: event.data.amount },
sharedContext
)
break
}
case PaymentActions.AUTHORIZED:
await this.authorizePaymentSession(
event.data.resource_id as string,
{},
sharedContext
)
}
}
async createProvidersOnLoad() {
const providersToLoad = this.__container__["payment_providers"]
const providers = await this.paymentProviderService_.list({
// @ts-ignore TODO
id: providersToLoad,
})
const loadedProvidersMap = new Map(providers.map((p) => [p.id, p]))
const providersToCreate: CreatePaymentProviderDTO[] = []
for (const id of providersToLoad) {
if (loadedProvidersMap.has(id)) {
continue
}
providersToCreate.push({ id })
}
await this.paymentProviderService_.create(providersToCreate)
}
}