1238 lines
33 KiB
TypeScript
1238 lines
33 KiB
TypeScript
import {
|
|
AccountHolderDTO,
|
|
BigNumberInput,
|
|
CaptureDTO,
|
|
Context,
|
|
CreateAccountHolderDTO,
|
|
CreateAccountHolderOutput,
|
|
CreateCaptureDTO,
|
|
CreatePaymentCollectionDTO,
|
|
CreatePaymentMethodDTO,
|
|
CreatePaymentSessionDTO,
|
|
CreateRefundDTO,
|
|
DAL,
|
|
FilterablePaymentCollectionProps,
|
|
FilterablePaymentMethodProps,
|
|
FilterablePaymentProviderProps,
|
|
FindConfig,
|
|
InferEntityType,
|
|
InitiatePaymentOutput,
|
|
InternalModuleDeclaration,
|
|
IPaymentModuleService,
|
|
Logger,
|
|
ModuleJoinerConfig,
|
|
ModulesSdkTypes,
|
|
PaymentCollectionDTO,
|
|
PaymentCollectionUpdatableFields,
|
|
PaymentDTO,
|
|
PaymentMethodDTO,
|
|
PaymentProviderDTO,
|
|
PaymentSessionDTO,
|
|
ProviderWebhookPayload,
|
|
RefundDTO,
|
|
RefundReasonDTO,
|
|
UpdateAccountHolderDTO,
|
|
UpdateAccountHolderOutput,
|
|
UpdatePaymentCollectionDTO,
|
|
UpdatePaymentDTO,
|
|
UpdatePaymentSessionDTO,
|
|
UpsertPaymentCollectionDTO,
|
|
WebhookActionResult,
|
|
} from "@medusajs/framework/types"
|
|
import {
|
|
BigNumber,
|
|
InjectManager,
|
|
InjectTransactionManager,
|
|
isPresent,
|
|
isString,
|
|
MathBN,
|
|
MedusaContext,
|
|
MedusaError,
|
|
ModulesSdkUtils,
|
|
PaymentCollectionStatus,
|
|
PaymentSessionStatus,
|
|
promiseAll,
|
|
} from "@medusajs/framework/utils"
|
|
import {
|
|
AccountHolder,
|
|
Capture,
|
|
Payment,
|
|
PaymentCollection,
|
|
PaymentSession,
|
|
Refund,
|
|
RefundReason,
|
|
} from "@models"
|
|
import { joinerConfig } from "../joiner-config"
|
|
import PaymentProviderService from "./payment-provider"
|
|
|
|
type InjectedDependencies = {
|
|
logger?: Logger
|
|
baseRepository: DAL.RepositoryService
|
|
paymentService: ModulesSdkTypes.IMedusaInternalService<any>
|
|
captureService: ModulesSdkTypes.IMedusaInternalService<any>
|
|
refundService: ModulesSdkTypes.IMedusaInternalService<any>
|
|
paymentSessionService: ModulesSdkTypes.IMedusaInternalService<any>
|
|
paymentCollectionService: ModulesSdkTypes.IMedusaInternalService<any>
|
|
accountHolderService: ModulesSdkTypes.IMedusaInternalService<any>
|
|
paymentProviderService: PaymentProviderService
|
|
}
|
|
|
|
const generateMethodForModels = {
|
|
PaymentCollection,
|
|
PaymentSession,
|
|
Payment,
|
|
Capture,
|
|
Refund,
|
|
RefundReason,
|
|
AccountHolder,
|
|
}
|
|
|
|
export default class PaymentModuleService
|
|
extends ModulesSdkUtils.MedusaService<{
|
|
PaymentCollection: { dto: PaymentCollectionDTO }
|
|
PaymentSession: { dto: PaymentSessionDTO }
|
|
Payment: { dto: PaymentDTO }
|
|
Capture: { dto: CaptureDTO }
|
|
Refund: { dto: RefundDTO }
|
|
RefundReason: { dto: RefundReasonDTO }
|
|
AccountHolder: { dto: AccountHolderDTO }
|
|
}>(generateMethodForModels)
|
|
implements IPaymentModuleService
|
|
{
|
|
protected baseRepository_: DAL.RepositoryService
|
|
|
|
protected paymentService_: ModulesSdkTypes.IMedusaInternalService<
|
|
typeof Payment
|
|
>
|
|
protected captureService_: ModulesSdkTypes.IMedusaInternalService<
|
|
typeof Capture
|
|
>
|
|
protected refundService_: ModulesSdkTypes.IMedusaInternalService<
|
|
typeof Refund
|
|
>
|
|
protected paymentSessionService_: ModulesSdkTypes.IMedusaInternalService<
|
|
typeof PaymentSession
|
|
>
|
|
protected paymentCollectionService_: ModulesSdkTypes.IMedusaInternalService<
|
|
typeof PaymentCollection
|
|
>
|
|
protected paymentProviderService_: PaymentProviderService
|
|
protected accountHolderService_: ModulesSdkTypes.IMedusaInternalService<
|
|
typeof AccountHolder
|
|
>
|
|
|
|
constructor(
|
|
{
|
|
baseRepository,
|
|
paymentService,
|
|
captureService,
|
|
refundService,
|
|
paymentSessionService,
|
|
paymentProviderService,
|
|
paymentCollectionService,
|
|
accountHolderService,
|
|
}: 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
|
|
this.accountHolderService_ = accountHolderService
|
|
}
|
|
|
|
__joinerConfig(): ModuleJoinerConfig {
|
|
return joinerConfig
|
|
}
|
|
|
|
protected roundToCurrencyPrecision(
|
|
amount: BigNumberInput,
|
|
currencyCode: string
|
|
): BigNumberInput {
|
|
let precision: number | undefined = undefined
|
|
try {
|
|
const formatted = Intl.NumberFormat(undefined, {
|
|
style: "currency",
|
|
currency: currencyCode,
|
|
}).format(0.1111111)
|
|
|
|
precision = formatted.split(".")[1].length
|
|
} catch {
|
|
// Unknown currency, keep the full precision
|
|
}
|
|
|
|
return MathBN.convert(amount, precision)
|
|
}
|
|
|
|
// @ts-expect-error
|
|
createPaymentCollections(
|
|
data: CreatePaymentCollectionDTO,
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO>
|
|
|
|
// @ts-expect-error
|
|
createPaymentCollections(
|
|
data: CreatePaymentCollectionDTO[],
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO[]>
|
|
@InjectManager()
|
|
|
|
// @ts-expect-error
|
|
async createPaymentCollections(
|
|
data: CreatePaymentCollectionDTO | CreatePaymentCollectionDTO[],
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
|
|
const input = Array.isArray(data) ? data : [data]
|
|
|
|
const collections = await this.createPaymentCollections_(
|
|
input,
|
|
sharedContext
|
|
)
|
|
|
|
return await this.baseRepository_.serialize<PaymentCollectionDTO[]>(
|
|
Array.isArray(data) ? collections : collections[0],
|
|
{
|
|
populate: true,
|
|
}
|
|
)
|
|
}
|
|
|
|
@InjectTransactionManager()
|
|
async createPaymentCollections_(
|
|
data: CreatePaymentCollectionDTO[],
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<InferEntityType<typeof PaymentCollection>[]> {
|
|
return await this.paymentCollectionService_.create(data, sharedContext)
|
|
}
|
|
|
|
// @ts-expect-error
|
|
updatePaymentCollections(
|
|
paymentCollectionId: string,
|
|
data: PaymentCollectionUpdatableFields,
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO>
|
|
// @ts-expect-error
|
|
updatePaymentCollections(
|
|
selector: FilterablePaymentCollectionProps,
|
|
data: PaymentCollectionUpdatableFields,
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO[]>
|
|
|
|
@InjectManager()
|
|
// @ts-expect-error
|
|
async updatePaymentCollections(
|
|
idOrSelector: string | FilterablePaymentCollectionProps,
|
|
data: PaymentCollectionUpdatableFields,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
|
|
let updateData: UpdatePaymentCollectionDTO[] = []
|
|
|
|
if (isString(idOrSelector)) {
|
|
updateData = [
|
|
{
|
|
id: idOrSelector,
|
|
...data,
|
|
},
|
|
]
|
|
} else {
|
|
const collections = await this.paymentCollectionService_.list(
|
|
idOrSelector,
|
|
{},
|
|
sharedContext
|
|
)
|
|
|
|
updateData = collections.map((c) => ({
|
|
id: c.id,
|
|
...data,
|
|
}))
|
|
}
|
|
|
|
const result = await this.updatePaymentCollections_(
|
|
updateData,
|
|
sharedContext
|
|
)
|
|
|
|
return await this.baseRepository_.serialize<PaymentCollectionDTO[]>(
|
|
Array.isArray(data) ? result : result[0],
|
|
{
|
|
populate: true,
|
|
}
|
|
)
|
|
}
|
|
|
|
@InjectManager()
|
|
async updatePaymentCollections_(
|
|
data: UpdatePaymentCollectionDTO[],
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<InferEntityType<typeof PaymentCollection>[]> {
|
|
return await this.paymentCollectionService_.update(data, sharedContext)
|
|
}
|
|
|
|
upsertPaymentCollections(
|
|
data: UpsertPaymentCollectionDTO[],
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO[]>
|
|
upsertPaymentCollections(
|
|
data: UpsertPaymentCollectionDTO,
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO>
|
|
|
|
@InjectManager()
|
|
async upsertPaymentCollections(
|
|
data: UpsertPaymentCollectionDTO | UpsertPaymentCollectionDTO[],
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
|
|
const input = Array.isArray(data) ? data : [data]
|
|
const forUpdate = input.filter(
|
|
(collection): collection is UpdatePaymentCollectionDTO => !!collection.id
|
|
)
|
|
const forCreate = input.filter(
|
|
(collection): collection is CreatePaymentCollectionDTO => !collection.id
|
|
)
|
|
|
|
const operations: Promise<InferEntityType<typeof PaymentCollection>[]>[] =
|
|
[]
|
|
|
|
if (forCreate.length) {
|
|
operations.push(this.createPaymentCollections_(forCreate, sharedContext))
|
|
}
|
|
if (forUpdate.length) {
|
|
operations.push(this.updatePaymentCollections_(forUpdate, sharedContext))
|
|
}
|
|
|
|
const result = (await promiseAll(operations)).flat()
|
|
|
|
return await this.baseRepository_.serialize<
|
|
PaymentCollectionDTO[] | PaymentCollectionDTO
|
|
>(Array.isArray(data) ? result : result[0])
|
|
}
|
|
|
|
completePaymentCollections(
|
|
paymentCollectionId: string,
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO>
|
|
completePaymentCollections(
|
|
paymentCollectionId: string[],
|
|
sharedContext?: Context
|
|
): Promise<PaymentCollectionDTO[]>
|
|
|
|
// Should we remove this and use `updatePaymentCollections` instead?
|
|
@InjectManager()
|
|
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()
|
|
async createPaymentSession(
|
|
paymentCollectionId: string,
|
|
input: CreatePaymentSessionDTO,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentSessionDTO> {
|
|
let paymentSession: InferEntityType<typeof PaymentSession> | undefined
|
|
let providerPaymentSession: InitiatePaymentOutput | undefined
|
|
|
|
try {
|
|
paymentSession = await this.createPaymentSession_(
|
|
paymentCollectionId,
|
|
input,
|
|
sharedContext
|
|
)
|
|
|
|
providerPaymentSession = await this.paymentProviderService_.createSession(
|
|
input.provider_id,
|
|
{
|
|
context: {
|
|
idempotency_key: paymentSession!.id,
|
|
...input.context,
|
|
},
|
|
data: { ...input.data, session_id: paymentSession!.id },
|
|
amount: input.amount,
|
|
currency_code: input.currency_code,
|
|
}
|
|
)
|
|
|
|
paymentSession = await this.paymentSessionService_.update(
|
|
{
|
|
id: paymentSession!.id,
|
|
data: { ...input.data, ...providerPaymentSession.data },
|
|
status: providerPaymentSession.status ?? PaymentSessionStatus.PENDING,
|
|
},
|
|
sharedContext
|
|
)
|
|
} catch (error) {
|
|
if (providerPaymentSession) {
|
|
await this.paymentProviderService_.deleteSession(input.provider_id, {
|
|
data: input.data,
|
|
})
|
|
}
|
|
|
|
if (paymentSession) {
|
|
await this.paymentSessionService_.delete(
|
|
paymentSession.id,
|
|
sharedContext
|
|
)
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
return await this.baseRepository_.serialize(paymentSession)
|
|
}
|
|
|
|
@InjectTransactionManager()
|
|
async createPaymentSession_(
|
|
paymentCollectionId: string,
|
|
data: CreatePaymentSessionDTO,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<InferEntityType<typeof 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,
|
|
metadata: data.metadata,
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
return paymentSession
|
|
}
|
|
|
|
@InjectManager()
|
|
async updatePaymentSession(
|
|
data: UpdatePaymentSessionDTO,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentSessionDTO> {
|
|
const session = await this.paymentSessionService_.retrieve(
|
|
data.id,
|
|
{ select: ["id", "status", "data", "provider_id"] },
|
|
sharedContext
|
|
)
|
|
|
|
const providerData = await this.paymentProviderService_.updateSession(
|
|
session.provider_id,
|
|
{
|
|
data: data.data,
|
|
amount: data.amount,
|
|
currency_code: data.currency_code,
|
|
context: data.context,
|
|
}
|
|
)
|
|
|
|
const updated = await this.paymentSessionService_.update(
|
|
{
|
|
id: session.id,
|
|
amount: data.amount,
|
|
currency_code: data.currency_code,
|
|
data: providerData.data,
|
|
// Allow the caller to explicitly set the status (eg. due to a webhook), fallback to the update response, and finally to the existing status.
|
|
status: data.status ?? providerData.status ?? session.status,
|
|
metadata: data.metadata,
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
return await this.baseRepository_.serialize(updated, { populate: true })
|
|
}
|
|
|
|
@InjectManager()
|
|
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(session.provider_id, {
|
|
data: session.data,
|
|
})
|
|
|
|
await this.paymentSessionService_.delete(id, sharedContext)
|
|
}
|
|
|
|
@InjectManager()
|
|
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",
|
|
"raw_amount",
|
|
"currency_code",
|
|
"authorized_at",
|
|
"payment_collection_id",
|
|
],
|
|
relations: ["payment", "payment_collection"],
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
// this method needs to be idempotent
|
|
if (session.payment && session.authorized_at) {
|
|
return await this.baseRepository_.serialize(session.payment, {
|
|
populate: true,
|
|
})
|
|
}
|
|
|
|
let { data, status } = await this.paymentProviderService_.authorizePayment(
|
|
session.provider_id,
|
|
{
|
|
data: session.data,
|
|
context: { idempotency_key: session.id, ...context },
|
|
}
|
|
)
|
|
|
|
if (
|
|
status !== PaymentSessionStatus.AUTHORIZED &&
|
|
status !== PaymentSessionStatus.CAPTURED
|
|
) {
|
|
await this.paymentSessionService_.update(
|
|
{
|
|
id: session.id,
|
|
status,
|
|
data,
|
|
},
|
|
sharedContext
|
|
)
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_ALLOWED,
|
|
`Session: ${session.id} was not authorized with the provider.`
|
|
)
|
|
}
|
|
|
|
let payment
|
|
try {
|
|
payment = await this.authorizePaymentSession_(
|
|
session,
|
|
data,
|
|
status as PaymentSessionStatus,
|
|
sharedContext
|
|
)
|
|
} catch (error) {
|
|
await this.paymentProviderService_.cancelPayment(session.provider_id, {
|
|
data,
|
|
context: {
|
|
idempotency_key: payment?.id,
|
|
...context,
|
|
},
|
|
})
|
|
|
|
throw error
|
|
}
|
|
|
|
await this.maybeUpdatePaymentCollection_(
|
|
session.payment_collection_id,
|
|
sharedContext
|
|
)
|
|
|
|
return await this.baseRepository_.serialize(payment, {
|
|
populate: true,
|
|
})
|
|
}
|
|
|
|
@InjectTransactionManager()
|
|
async authorizePaymentSession_(
|
|
session: InferEntityType<typeof PaymentSession>,
|
|
data: Record<string, unknown> | undefined,
|
|
status: PaymentSessionStatus,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<InferEntityType<typeof Payment>> {
|
|
let autoCapture = false
|
|
if (status === PaymentSessionStatus.CAPTURED) {
|
|
status = PaymentSessionStatus.AUTHORIZED
|
|
autoCapture = true
|
|
}
|
|
|
|
await this.paymentSessionService_.update(
|
|
{
|
|
id: session.id,
|
|
data,
|
|
status,
|
|
...(session.authorized_at === null
|
|
? {
|
|
authorized_at: new Date(),
|
|
}
|
|
: {}),
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
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,
|
|
data,
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
if (autoCapture) {
|
|
await this.capturePayment(
|
|
{ payment_id: payment.id, amount: session.amount as BigNumberInput },
|
|
sharedContext
|
|
)
|
|
}
|
|
|
|
return payment
|
|
}
|
|
|
|
@InjectManager()
|
|
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)
|
|
}
|
|
|
|
// TODO: This method should return a capture, not a payment
|
|
@InjectManager()
|
|
async capturePayment(
|
|
data: CreateCaptureDTO,
|
|
@MedusaContext() sharedContext: Context = {}
|
|
): Promise<PaymentDTO> {
|
|
const payment = await this.paymentService_.retrieve(
|
|
data.payment_id,
|
|
{
|
|
select: [
|
|
"id",
|
|
"data",
|
|
"provider_id",
|
|
"payment_collection_id",
|
|
"amount",
|
|
"raw_amount",
|
|
"currency_code",
|
|
"captured_at",
|
|
"canceled_at",
|
|
],
|
|
relations: ["captures.raw_amount"],
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
const { isFullyCaptured, capture } = await this.capturePayment_(
|
|
data,
|
|
payment,
|
|
sharedContext
|
|
)
|
|
|
|
try {
|
|
await this.capturePaymentFromProvider_(
|
|
payment,
|
|
capture,
|
|
isFullyCaptured,
|
|
sharedContext
|
|
)
|
|
} catch (error) {
|
|
if (capture?.id) {
|
|
await super.deleteCaptures({ id: capture.id }, sharedContext)
|
|
}
|
|
throw error
|
|
}
|
|
|
|
await this.maybeUpdatePaymentCollection_(
|
|
payment.payment_collection_id,
|
|
sharedContext
|
|
)
|
|
|
|
return await this.baseRepository_.serialize(payment, {
|
|
populate: true,
|
|
})
|
|
}
|
|
|
|
@InjectTransactionManager()
|
|
private async capturePayment_(
|
|
data: CreateCaptureDTO,
|
|
payment: InferEntityType<typeof Payment>,
|
|
@MedusaContext() sharedContext: Context = {}
|
|
): Promise<{
|
|
isFullyCaptured: boolean
|
|
capture?: InferEntityType<typeof Capture>
|
|
}> {
|
|
if (payment.canceled_at) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`The payment: ${payment.id} has been canceled.`
|
|
)
|
|
}
|
|
|
|
if (payment.captured_at) {
|
|
return { isFullyCaptured: true }
|
|
}
|
|
|
|
// If no custom amount is passed, we assume the full amount needs to be captured
|
|
if (!data.amount) {
|
|
data.amount = payment.amount as number
|
|
}
|
|
|
|
const capturedAmount = payment.captures.reduce((captureAmount, next) => {
|
|
return MathBN.add(captureAmount, next.raw_amount as BigNumberInput)
|
|
}, MathBN.convert(0))
|
|
|
|
const authorizedAmount = new BigNumber(payment.raw_amount as BigNumberInput)
|
|
const newCaptureAmount = new BigNumber(data.amount)
|
|
const remainingToCapture = MathBN.sub(authorizedAmount, capturedAmount)
|
|
|
|
if (
|
|
MathBN.gt(
|
|
this.roundToCurrencyPrecision(newCaptureAmount, payment.currency_code),
|
|
this.roundToCurrencyPrecision(remainingToCapture, payment.currency_code)
|
|
)
|
|
) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`You cannot capture more than the authorized amount substracted by what is already captured.`
|
|
)
|
|
}
|
|
|
|
// When the entire authorized amount has been captured, we return it as complete
|
|
const totalCaptured = MathBN.convert(
|
|
MathBN.add(capturedAmount, newCaptureAmount)
|
|
)
|
|
const isFullyCaptured = MathBN.gte(
|
|
this.roundToCurrencyPrecision(totalCaptured, payment.currency_code),
|
|
this.roundToCurrencyPrecision(authorizedAmount, payment.currency_code)
|
|
)
|
|
|
|
const capture = await this.captureService_.create(
|
|
{
|
|
payment: data.payment_id,
|
|
amount: data.amount,
|
|
captured_by: data.captured_by,
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
return { isFullyCaptured, capture }
|
|
}
|
|
@InjectManager()
|
|
private async capturePaymentFromProvider_(
|
|
payment: InferEntityType<typeof Payment>,
|
|
capture: InferEntityType<typeof Capture> | undefined,
|
|
isFullyCaptured: boolean,
|
|
@MedusaContext() sharedContext: Context = {}
|
|
) {
|
|
const paymentData = await this.paymentProviderService_.capturePayment(
|
|
payment.provider_id,
|
|
{
|
|
data: payment.data!,
|
|
context: {
|
|
idempotency_key: capture?.id,
|
|
},
|
|
}
|
|
)
|
|
|
|
await this.paymentService_.update(
|
|
{
|
|
id: payment.id,
|
|
data: paymentData.data,
|
|
captured_at: isFullyCaptured ? new Date() : undefined,
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
return payment
|
|
}
|
|
|
|
@InjectManager()
|
|
async refundPayment(
|
|
data: CreateRefundDTO,
|
|
@MedusaContext() sharedContext: Context = {}
|
|
): Promise<PaymentDTO> {
|
|
const payment = await this.paymentService_.retrieve(
|
|
data.payment_id,
|
|
{
|
|
select: [
|
|
"id",
|
|
"data",
|
|
"provider_id",
|
|
"payment_collection_id",
|
|
"amount",
|
|
"raw_amount",
|
|
],
|
|
relations: ["captures.raw_amount", "refunds.raw_amount"],
|
|
},
|
|
sharedContext
|
|
)
|
|
const refund = await this.refundPayment_(payment, data, sharedContext)
|
|
|
|
try {
|
|
await this.refundPaymentFromProvider_(payment, refund, sharedContext)
|
|
} catch (error) {
|
|
await super.deleteRefunds({ id: refund.id }, sharedContext)
|
|
throw error
|
|
}
|
|
|
|
await this.maybeUpdatePaymentCollection_(
|
|
payment.payment_collection_id,
|
|
sharedContext
|
|
)
|
|
|
|
return await this.retrievePayment(
|
|
payment.id,
|
|
{ relations: ["refunds"] },
|
|
sharedContext
|
|
)
|
|
}
|
|
|
|
@InjectTransactionManager()
|
|
private async refundPayment_(
|
|
payment: InferEntityType<typeof Payment>,
|
|
data: CreateRefundDTO,
|
|
@MedusaContext() sharedContext: Context = {}
|
|
): Promise<InferEntityType<typeof Refund>> {
|
|
if (!data.amount) {
|
|
data.amount = payment.amount as BigNumberInput
|
|
}
|
|
|
|
const capturedAmount = payment.captures.reduce((captureAmount, next) => {
|
|
const amountAsBigNumber = new BigNumber(next.raw_amount as BigNumberInput)
|
|
return MathBN.add(captureAmount, amountAsBigNumber)
|
|
}, MathBN.convert(0))
|
|
const refundedAmount = payment.refunds.reduce((refundedAmount, next) => {
|
|
return MathBN.add(refundedAmount, next.raw_amount as BigNumberInput)
|
|
}, MathBN.convert(0))
|
|
|
|
const totalRefundedAmount = MathBN.add(refundedAmount, data.amount)
|
|
|
|
if (MathBN.lt(capturedAmount, totalRefundedAmount)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`You cannot refund more than what is captured on the payment.`
|
|
)
|
|
}
|
|
|
|
const refund = await this.refundService_.create(
|
|
{
|
|
payment: data.payment_id,
|
|
amount: data.amount,
|
|
created_by: data.created_by,
|
|
note: data.note,
|
|
refund_reason_id: data.refund_reason_id,
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
return refund
|
|
}
|
|
|
|
@InjectManager()
|
|
private async refundPaymentFromProvider_(
|
|
payment: InferEntityType<typeof Payment>,
|
|
refund: InferEntityType<typeof Refund>,
|
|
@MedusaContext() sharedContext: Context = {}
|
|
) {
|
|
const paymentData = await this.paymentProviderService_.refundPayment(
|
|
payment.provider_id,
|
|
{
|
|
data: payment.data!,
|
|
amount: refund.raw_amount as BigNumberInput,
|
|
context: {
|
|
idempotency_key: refund.id,
|
|
},
|
|
}
|
|
)
|
|
|
|
await this.paymentService_.update(
|
|
{ id: payment.id, data: paymentData.data },
|
|
sharedContext
|
|
)
|
|
|
|
return payment
|
|
}
|
|
|
|
@InjectManager()
|
|
async cancelPayment(
|
|
paymentId: string,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentDTO> {
|
|
const payment = await this.paymentService_.retrieve(
|
|
paymentId,
|
|
{ select: ["id", "data", "provider_id"] },
|
|
sharedContext
|
|
)
|
|
|
|
await this.paymentProviderService_.cancelPayment(payment.provider_id, {
|
|
data: payment.data!,
|
|
context: {
|
|
idempotency_key: payment.id,
|
|
},
|
|
})
|
|
|
|
await this.paymentService_.update(
|
|
{ id: paymentId, canceled_at: new Date() },
|
|
sharedContext
|
|
)
|
|
|
|
return await this.retrievePayment(payment.id, {}, sharedContext)
|
|
}
|
|
|
|
@InjectManager()
|
|
private async maybeUpdatePaymentCollection_(
|
|
paymentCollectionId: string,
|
|
sharedContext?: Context
|
|
) {
|
|
const paymentCollection = await this.paymentCollectionService_.retrieve(
|
|
paymentCollectionId,
|
|
{
|
|
select: ["amount", "raw_amount", "status", "currency_code"],
|
|
relations: [
|
|
"payment_sessions.amount",
|
|
"payment_sessions.raw_amount",
|
|
"payments.captures.amount",
|
|
"payments.captures.raw_amount",
|
|
"payments.refunds.amount",
|
|
"payments.refunds.raw_amount",
|
|
],
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
const paymentSessions = paymentCollection.payment_sessions
|
|
const captures = paymentCollection.payments
|
|
.map((pay) => [...pay.captures])
|
|
.flat()
|
|
const refunds = paymentCollection.payments
|
|
.map((pay) => [...pay.refunds])
|
|
.flat()
|
|
|
|
let authorizedAmount = MathBN.convert(0)
|
|
let capturedAmount = MathBN.convert(0)
|
|
let refundedAmount = MathBN.convert(0)
|
|
let completedAt: Date | undefined
|
|
|
|
for (const ps of paymentSessions) {
|
|
if (ps.status === PaymentSessionStatus.AUTHORIZED) {
|
|
authorizedAmount = MathBN.add(authorizedAmount, ps.amount)
|
|
}
|
|
}
|
|
|
|
for (const capture of captures) {
|
|
capturedAmount = MathBN.add(capturedAmount, capture.amount)
|
|
}
|
|
|
|
for (const refund of refunds) {
|
|
refundedAmount = MathBN.add(refundedAmount, refund.amount)
|
|
}
|
|
|
|
let status =
|
|
paymentSessions.length === 0
|
|
? PaymentCollectionStatus.NOT_PAID
|
|
: PaymentCollectionStatus.AWAITING
|
|
|
|
if (MathBN.gt(authorizedAmount, 0)) {
|
|
status = MathBN.gte(
|
|
this.roundToCurrencyPrecision(
|
|
authorizedAmount,
|
|
paymentCollection.currency_code
|
|
),
|
|
this.roundToCurrencyPrecision(
|
|
paymentCollection.amount,
|
|
paymentCollection.currency_code
|
|
)
|
|
)
|
|
? PaymentCollectionStatus.AUTHORIZED
|
|
: PaymentCollectionStatus.PARTIALLY_AUTHORIZED
|
|
}
|
|
|
|
if (
|
|
MathBN.gte(
|
|
this.roundToCurrencyPrecision(
|
|
capturedAmount,
|
|
paymentCollection.currency_code
|
|
),
|
|
this.roundToCurrencyPrecision(
|
|
paymentCollection.amount,
|
|
paymentCollection.currency_code
|
|
)
|
|
)
|
|
) {
|
|
status = PaymentCollectionStatus.COMPLETED
|
|
completedAt = new Date()
|
|
}
|
|
|
|
await this.paymentCollectionService_.update(
|
|
{
|
|
id: paymentCollectionId,
|
|
status,
|
|
authorized_amount: authorizedAmount,
|
|
captured_amount: capturedAmount,
|
|
refunded_amount: refundedAmount,
|
|
completed_at: completedAt,
|
|
},
|
|
sharedContext
|
|
)
|
|
}
|
|
|
|
@InjectManager()
|
|
async listPaymentProviders(
|
|
filters: FilterablePaymentProviderProps = {},
|
|
config: FindConfig<PaymentProviderDTO> = {},
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentProviderDTO[]> {
|
|
const providers = await this.paymentProviderService_.list(
|
|
filters,
|
|
config,
|
|
sharedContext
|
|
)
|
|
|
|
return await this.baseRepository_.serialize<PaymentProviderDTO[]>(
|
|
providers,
|
|
{
|
|
populate: true,
|
|
}
|
|
)
|
|
}
|
|
|
|
@InjectManager()
|
|
async listAndCountPaymentProviders(
|
|
filters: FilterablePaymentProviderProps = {},
|
|
config: FindConfig<PaymentProviderDTO> = {},
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<[PaymentProviderDTO[], number]> {
|
|
const [providers, count] = await this.paymentProviderService_.listAndCount(
|
|
filters,
|
|
config,
|
|
sharedContext
|
|
)
|
|
|
|
return [
|
|
await this.baseRepository_.serialize<PaymentProviderDTO[]>(providers, {
|
|
populate: true,
|
|
}),
|
|
count,
|
|
]
|
|
}
|
|
|
|
@InjectManager()
|
|
async createAccountHolder(
|
|
input: CreateAccountHolderDTO,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<AccountHolderDTO> {
|
|
if (input.context?.account_holder) {
|
|
return input.context.account_holder as AccountHolderDTO
|
|
}
|
|
|
|
let accountHolder: InferEntityType<typeof AccountHolder> | undefined
|
|
let providerAccountHolder: CreateAccountHolderOutput | undefined
|
|
|
|
providerAccountHolder =
|
|
await this.paymentProviderService_.createAccountHolder(
|
|
input.provider_id,
|
|
{
|
|
context: {
|
|
idempotency_key: input.context?.customer?.id,
|
|
...input.context,
|
|
},
|
|
}
|
|
)
|
|
|
|
// This can be empty when either the method is not supported or an account holder wasn't created
|
|
if (isPresent(providerAccountHolder)) {
|
|
accountHolder = await this.accountHolderService_.create(
|
|
{
|
|
external_id: providerAccountHolder.id,
|
|
email: input.context.customer?.email,
|
|
data: providerAccountHolder.data,
|
|
provider_id: input.provider_id,
|
|
},
|
|
sharedContext
|
|
)
|
|
}
|
|
|
|
return await this.baseRepository_.serialize(accountHolder)
|
|
}
|
|
|
|
@InjectManager()
|
|
async updateAccountHolder(
|
|
input: UpdateAccountHolderDTO,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<AccountHolderDTO> {
|
|
if (!input.context?.account_holder) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
"Missing account holder data while updating account holder."
|
|
)
|
|
}
|
|
|
|
let accountHolder: InferEntityType<typeof AccountHolder> | undefined
|
|
let providerAccountHolder: UpdateAccountHolderOutput | undefined
|
|
|
|
providerAccountHolder =
|
|
await this.paymentProviderService_.updateAccountHolder(
|
|
input.provider_id,
|
|
{
|
|
context: input.context,
|
|
}
|
|
)
|
|
|
|
// The data field can be empty when either the method is not supported or an account holder wasn't updated
|
|
// We still want to do the update as we might only be updating the metadata
|
|
accountHolder = await this.accountHolderService_.update(
|
|
{
|
|
id: input.id,
|
|
...(providerAccountHolder?.data
|
|
? { data: providerAccountHolder.data }
|
|
: {}),
|
|
metadata: input.metadata,
|
|
},
|
|
sharedContext
|
|
)
|
|
|
|
return await this.baseRepository_.serialize(accountHolder)
|
|
}
|
|
|
|
@InjectManager()
|
|
async deleteAccountHolder(
|
|
id: string,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<void> {
|
|
const accountHolder = await this.accountHolderService_.retrieve(
|
|
id,
|
|
{ select: ["id", "provider_id", "external_id", "email", "data"] },
|
|
sharedContext
|
|
)
|
|
|
|
await this.accountHolderService_.delete(id, sharedContext)
|
|
|
|
await this.paymentProviderService_.deleteAccountHolder(
|
|
accountHolder.provider_id,
|
|
{
|
|
context: { account_holder: accountHolder },
|
|
}
|
|
)
|
|
}
|
|
|
|
@InjectManager()
|
|
async listPaymentMethods(
|
|
filters: FilterablePaymentMethodProps,
|
|
config: FindConfig<PaymentMethodDTO> = {},
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentMethodDTO[]> {
|
|
const res = await this.paymentProviderService_.listPaymentMethods(
|
|
filters.provider_id,
|
|
{ context: filters.context }
|
|
)
|
|
|
|
return res.map((item) => ({
|
|
id: item.id,
|
|
data: item.data!,
|
|
provider_id: filters.provider_id,
|
|
}))
|
|
}
|
|
|
|
@InjectManager()
|
|
async listAndCountPaymentMethods(
|
|
filters: FilterablePaymentMethodProps,
|
|
config: FindConfig<PaymentMethodDTO> = {},
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<[PaymentMethodDTO[], number]> {
|
|
const paymentMethods =
|
|
await this.paymentProviderService_.listPaymentMethods(
|
|
filters.provider_id,
|
|
{ context: filters.context }
|
|
)
|
|
|
|
const normalizedResponse = paymentMethods.map((item) => ({
|
|
id: item.id,
|
|
data: item.data!,
|
|
provider_id: filters.provider_id,
|
|
}))
|
|
|
|
return [normalizedResponse, paymentMethods.length]
|
|
}
|
|
|
|
// @ts-ignore
|
|
createPaymentMethods(
|
|
data: CreatePaymentMethodDTO,
|
|
sharedContext?: Context
|
|
): Promise<PaymentMethodDTO>
|
|
|
|
createPaymentMethods(
|
|
data: CreatePaymentMethodDTO[],
|
|
sharedContext?: Context
|
|
): Promise<PaymentMethodDTO[]>
|
|
@InjectManager()
|
|
async createPaymentMethods(
|
|
data: CreatePaymentMethodDTO | CreatePaymentMethodDTO[],
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<PaymentMethodDTO | PaymentMethodDTO[]> {
|
|
const input = Array.isArray(data) ? data : [data]
|
|
|
|
const result = await promiseAll(
|
|
input.map((item) =>
|
|
this.paymentProviderService_.savePaymentMethod(item.provider_id, item)
|
|
),
|
|
{ aggregateErrors: true }
|
|
)
|
|
|
|
const normalizedResponse = result.map((item, i) => {
|
|
return {
|
|
id: item.id,
|
|
data: item.data!,
|
|
provider_id: input[i].provider_id,
|
|
}
|
|
})
|
|
|
|
return Array.isArray(data) ? normalizedResponse : normalizedResponse[0]
|
|
}
|
|
|
|
@InjectManager()
|
|
async getWebhookActionAndData(
|
|
eventData: ProviderWebhookPayload,
|
|
@MedusaContext() sharedContext?: Context
|
|
): Promise<WebhookActionResult> {
|
|
const providerId = `pp_${eventData.provider}`
|
|
|
|
return await this.paymentProviderService_.getWebhookActionAndData(
|
|
providerId,
|
|
eventData.payload
|
|
)
|
|
}
|
|
}
|