feat(payment): provider service (#6308)

This commit is contained in:
Frane Polić
2024-02-12 20:57:41 +01:00
committed by GitHub
parent 869dc751a0
commit a6a4b3f01a
21 changed files with 1511 additions and 637 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat(types, utils): payment module - provider service

View File

@@ -24,21 +24,21 @@ export const defaultPaymentSessionData = [
id: "pay-sess-id-1",
amount: 100,
currency_code: "usd",
provider_id: "manual",
provider_id: "system",
payment_collection: "pay-col-id-1",
},
{
id: "pay-sess-id-2",
amount: 100,
currency_code: "usd",
provider_id: "manual",
provider_id: "system",
payment_collection: "pay-col-id-2",
},
{
id: "pay-sess-id-3",
amount: 100,
currency_code: "usd",
provider_id: "manual",
provider_id: "system",
payment_collection: "pay-col-id-2",
},
]
@@ -50,7 +50,7 @@ export const defaultPaymentData = [
currency_code: "usd",
payment_collection: "pay-col-id-1",
payment_session: "pay-sess-id-1",
provider_id: "manual",
provider_id: "system",
authorized_amount: 100,
data: {},
},
@@ -61,7 +61,7 @@ export const defaultPaymentData = [
currency_code: "usd",
payment_collection: "pay-col-id-2",
payment_session: "pay-sess-id-2",
provider_id: "manual",
provider_id: "system",
data: {},
},
]

View File

@@ -1,50 +1,35 @@
import { IPaymentModuleService } from "@medusajs/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { initModules } from "medusa-test-utils"
import { Modules } from "@medusajs/modules-sdk"
import { initialize } from "../../../../src"
import { DB_URL, MikroOrmWrapper } from "../../../utils"
import { MikroOrmWrapper } from "../../../utils"
import {
createPaymentCollections,
createPaymentSessions,
createPayments,
} from "../../../__fixtures__"
import { getInitModuleConfig } from "../../../utils/get-init-module-config"
import { initModules } from "medusa-test-utils"
import { Modules } from "@medusajs/modules-sdk"
jest.setTimeout(30000)
describe("Payment Module Service", () => {
let service: IPaymentModuleService
describe("PaymentCollection", () => {
let repositoryManager: SqlEntityManager
describe("Payment Flow", () => {
let service: IPaymentModuleService
let shutdownFunc: () => Promise<void>
beforeAll(async () => {
const initModulesConfig = getInitModuleConfig()
const { medusaApp, shutdown } = await initModules(initModulesConfig)
service = medusaApp.modules[Modules.PAYMENT]
shutdownFunc = shutdown
})
afterAll(async () => {
await shutdownFunc()
})
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
repositoryManager = await MikroOrmWrapper.forkManager()
const repositoryManager = await MikroOrmWrapper.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA,
},
})
const initModulesConfig = getInitModuleConfig()
const { medusaApp, shutdown } = await initModules(initModulesConfig)
service = medusaApp.modules[Modules.PAYMENT]
shutdownFunc = shutdown
await createPaymentCollections(repositoryManager)
await createPaymentSessions(repositoryManager)
@@ -53,12 +38,120 @@ describe("Payment Module Service", () => {
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
await shutdownFunc()
})
it("complete payment flow successfully", async () => {
let paymentCollection = await service.createPaymentCollections({
currency_code: "USD",
amount: 200,
region_id: "reg_123",
})
const paymentSession = await service.createPaymentSession(
paymentCollection.id,
{
provider_id: "system",
providerContext: {
amount: 200,
currency_code: "USD",
payment_session_data: {},
context: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
resource_id: "cart_test",
},
}
)
const payment = await service.authorizePaymentSession(
paymentSession.id,
{}
)
await service.capturePayment({
amount: 200,
payment_id: payment.id,
})
await service.completePaymentCollections(paymentCollection.id)
paymentCollection = await service.retrievePaymentCollection(
paymentCollection.id,
{ relations: ["payment_sessions", "payments.captures"] }
)
expect(paymentCollection).toEqual(
expect.objectContaining({
id: expect.any(String),
currency_code: "USD",
amount: 200,
// TODO
// authorized_amount: 200,
// status: "authorized",
region_id: "reg_123",
deleted_at: null,
completed_at: expect.any(Date),
payment_sessions: [
expect.objectContaining({
id: expect.any(String),
currency_code: "USD",
amount: 200,
provider_id: "system",
status: "authorized",
authorized_at: expect.any(Date),
}),
],
payments: [
expect.objectContaining({
id: expect.any(String),
amount: 200,
currency_code: "USD",
provider_id: "system",
captures: [
expect.objectContaining({
amount: 200,
}),
],
}),
],
})
)
})
})
describe("PaymentCollection", () => {
let service: IPaymentModuleService
let shutdownFunc: () => Promise<void>
afterAll(async () => {
await shutdownFunc()
})
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
const repositoryManager = await MikroOrmWrapper.forkManager()
const initModulesConfig = getInitModuleConfig()
const { medusaApp, shutdown } = await initModules(initModulesConfig)
service = medusaApp.modules[Modules.PAYMENT]
shutdownFunc = shutdown
await createPaymentCollections(repositoryManager)
await createPaymentSessions(repositoryManager)
await createPayments(repositoryManager)
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
await shutdownFunc()
})
describe("create", () => {
it("should throw an error when required params are not passed", async () => {
let error = await service
.createPaymentCollection([
.createPaymentCollections([
{
amount: 200,
region_id: "req_123",
@@ -71,7 +164,7 @@ describe("Payment Module Service", () => {
)
error = await service
.createPaymentCollection([
.createPaymentCollections([
{
currency_code: "USD",
region_id: "req_123",
@@ -84,7 +177,7 @@ describe("Payment Module Service", () => {
)
error = await service
.createPaymentCollection([
.createPaymentCollections([
{
currency_code: "USD",
amount: 200,
@@ -99,7 +192,7 @@ describe("Payment Module Service", () => {
it("should create a payment collection successfully", async () => {
const [createdPaymentCollection] =
await service.createPaymentCollection([
await service.createPaymentCollections([
{ currency_code: "USD", amount: 200, region_id: "reg_123" },
])
@@ -220,10 +313,10 @@ describe("Payment Module Service", () => {
describe("update", () => {
it("should update a Payment Collection", async () => {
await service.updatePaymentCollection({
await service.updatePaymentCollections({
id: "pay-col-id-2",
currency_code: "eur",
authorized_amount: 200,
region_id: "reg-2",
})
const collection = await service.retrievePaymentCollection(
@@ -233,84 +326,48 @@ describe("Payment Module Service", () => {
expect(collection).toEqual(
expect.objectContaining({
id: "pay-col-id-2",
authorized_amount: 200,
region_id: "reg-2",
currency_code: "eur",
})
)
})
})
describe("complete", () => {
it("should complete a Payment Collection", async () => {
await service.completePaymentCollections("pay-col-id-1")
const collection = await service.retrievePaymentCollection(
"pay-col-id-1"
)
expect(collection).toEqual(
expect.objectContaining({
id: "pay-col-id-1",
completed_at: expect.any(Date),
})
)
})
})
})
describe("PaymentSession", () => {
let repositoryManager: SqlEntityManager
let service: IPaymentModuleService
let shutdownFunc: () => Promise<void>
afterAll(async () => {
await shutdownFunc()
})
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
repositoryManager = await MikroOrmWrapper.forkManager()
const repositoryManager = await MikroOrmWrapper.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA,
},
})
const initModulesConfig = getInitModuleConfig()
const { medusaApp, shutdown } = await initModules(initModulesConfig)
service = medusaApp.modules[Modules.PAYMENT]
await createPaymentCollections(repositoryManager)
await createPaymentSessions(repositoryManager)
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
})
describe("create", () => {
it("should create a payment session successfully", async () => {
const paymentCollection = await service.createPaymentSession(
"pay-col-id-1",
{
amount: 200,
provider_id: "manual",
currency_code: "usd",
}
)
expect(paymentCollection).toEqual(
expect.objectContaining({
id: "pay-col-id-1",
status: "not_paid",
payment_sessions: expect.arrayContaining([
{
id: expect.any(String),
data: null,
status: "pending",
authorized_at: null,
currency_code: "usd",
amount: 200,
provider_id: "manual",
payment_collection: expect.objectContaining({
id: paymentCollection.id,
}),
},
]),
})
)
})
})
})
describe("Payment", () => {
let repositoryManager: SqlEntityManager
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
repositoryManager = await MikroOrmWrapper.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA,
},
})
shutdownFunc = shutdown
await createPaymentCollections(repositoryManager)
await createPaymentSessions(repositoryManager)
@@ -319,61 +376,197 @@ describe("Payment Module Service", () => {
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
await shutdownFunc()
})
describe("create", () => {
it("should create a payment successfully", async () => {
let paymentCollection = await service.createPaymentCollection({
currency_code: "usd",
amount: 200,
region_id: "reg",
it("should create a payment session successfully", async () => {
await service.createPaymentSession("pay-col-id-1", {
provider_id: "system",
providerContext: {
amount: 200,
currency_code: "usd",
payment_session_data: {},
context: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
resource_id: "cart_test",
},
})
paymentCollection = await service.createPaymentSession(
paymentCollection.id,
{
amount: 200,
provider_id: "manual",
currency_code: "usd",
}
const paymentCollection = await service.retrievePaymentCollection(
"pay-col-id-1",
{ relations: ["payment_sessions"] }
)
const createdPayment = await service.createPayment({
data: {},
amount: 200,
provider_id: "manual",
currency_code: "usd",
payment_collection_id: paymentCollection.id,
payment_session_id: paymentCollection.payment_sessions![0].id,
expect(paymentCollection).toEqual(
expect.objectContaining({
id: "pay-col-id-1",
status: "not_paid",
payment_sessions: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
data: {},
status: "pending",
authorized_at: null,
currency_code: "usd",
amount: 200,
provider_id: "system",
}),
]),
})
)
})
})
describe("update", () => {
it("should update a payment session successfully", async () => {
let session = await service.createPaymentSession("pay-col-id-1", {
provider_id: "system",
providerContext: {
amount: 200,
currency_code: "usd",
payment_session_data: {},
context: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
resource_id: "cart_test",
},
})
expect(createdPayment).toEqual(
session = await service.updatePaymentSession({
id: session.id,
providerContext: {
amount: 200,
currency_code: "eur",
resource_id: "res_id",
context: {},
customer: {},
billing_address: {},
email: "new@test.tsst",
payment_session_data: {},
},
})
expect(session).toEqual(
expect.objectContaining({
id: expect.any(String),
authorized_amount: null,
status: "pending",
currency_code: "eur",
amount: 200,
})
)
})
})
describe("authorize", () => {
it("should authorize a payment session", async () => {
const collection = await service.createPaymentCollections({
amount: 200,
region_id: "test-region",
currency_code: "usd",
})
const session = await service.createPaymentSession(collection.id, {
provider_id: "system",
providerContext: {
amount: 100,
currency_code: "usd",
payment_session_data: {},
context: {},
resource_id: "test",
email: "test@test.com",
billing_address: {},
customer: {},
},
})
const payment = await service.authorizePaymentSession(session.id, {})
expect(payment).toEqual(
expect.objectContaining({
id: expect.any(String),
amount: 100,
authorized_amount: 100,
currency_code: "usd",
provider_id: "system",
refunds: [],
captures: [],
data: {},
cart_id: null,
order_id: null,
order_edit_id: null,
customer_id: null,
data: {},
deleted_at: null,
captured_at: null,
canceled_at: null,
refunds: [],
captures: [],
amount: 200,
currency_code: "usd",
provider_id: "manual",
payment_collection: expect.objectContaining({
id: paymentCollection.id,
}),
payment_session: expect.objectContaining({
id: paymentCollection.payment_sessions![0].id,
id: expect.any(String),
}),
payment_session: {
id: expect.any(String),
currency_code: "usd",
amount: 100,
provider_id: "system",
data: {},
status: "authorized",
authorized_at: expect.any(Date),
payment_collection: expect.objectContaining({
id: expect.any(String),
}),
payment: expect.objectContaining({
authorized_amount: 100,
cart_id: null,
order_id: null,
order_edit_id: null,
customer_id: null,
data: {},
deleted_at: null,
captured_at: null,
canceled_at: null,
refunds: [],
captures: [],
amount: 100,
currency_code: "usd",
provider_id: "system",
}),
},
})
)
})
})
})
describe("Payment", () => {
let service: IPaymentModuleService
let shutdownFunc: () => Promise<void>
afterAll(async () => {
await shutdownFunc()
})
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
const repositoryManager = await MikroOrmWrapper.forkManager()
const initModulesConfig = getInitModuleConfig()
const { medusaApp, shutdown } = await initModules(initModulesConfig)
service = medusaApp.modules[Modules.PAYMENT]
shutdownFunc = shutdown
await createPaymentCollections(repositoryManager)
await createPaymentSessions(repositoryManager)
await createPayments(repositoryManager)
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
await shutdownFunc()
})
describe("update", () => {
it("should update a payment successfully", async () => {
@@ -417,83 +610,21 @@ describe("Payment Module Service", () => {
)
})
it("should capture payments in bulk successfully", async () => {
const capturedPayments = await service.capturePayment([
{
amount: 50, // partially captured
payment_id: "pay-id-1",
},
{
amount: 100, // fully captured
payment_id: "pay-id-2",
},
])
expect(capturedPayments).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "pay-id-1",
amount: 100,
authorized_amount: 100,
captured_at: null,
captures: [
expect.objectContaining({
created_by: null,
amount: 50,
}),
],
// captured_amount: 50,
}),
expect.objectContaining({
id: "pay-id-2",
amount: 100,
authorized_amount: 100,
// captured_at: expect.any(Date),
captures: [
expect.objectContaining({
created_by: null,
amount: 100,
}),
],
// captured_amount: 100,
}),
])
)
})
// TODO: uncomment when totals are implemented
// it("should fail to capture payments in bulk if one of the captures fail", async () => {
// const error = await service
// .capturePayment([
// {
// amount: 50,
// it("should fail to capture amount greater than authorized", async () => {
// const error = await service
// .capturePayment({
// amount: 200,
// payment_id: "pay-id-1",
// },
// {
// amount: 200, // exceeds authorized amount
// payment_id: "pay-id-2",
// },
// ])
// .catch((e) => e)
// })
// .catch((e) => e)
//
// expect(error.message).toEqual(
// "Total captured amount for payment: pay-id-2 exceeds authorized amount."
// )
// })
// it("should fail to capture amount greater than authorized", async () => {
// const error = await service
// .capturePayment({
// amount: 200,
// payment_id: "pay-id-1",
// })
// .catch((e) => e)
// expect(error.message).toEqual(
// "Total captured amount for payment: pay-id-1 exceeds authorised amount."
// )
// })
//
// expect(error.message).toEqual(
// "Total captured amount for payment: pay-id-1 exceeds authorized amount."
// )
// })
// it("should fail to capture already captured payment", async () => {
// await service.capturePayment({
// amount: 100,
@@ -507,60 +638,52 @@ describe("Payment Module Service", () => {
// })
// .catch((e) => e)
//
// expect(error.message).toEqual("The payment is already fully captured.")
// expect(error.message).toEqual(
// "The payment: pay-id-1 is already fully captured."
// )
// })
//
// it("should fail to capture a canceled payment", async () => {
// await service.cancelPayment("pay-id-1")
//
// const error = await service
// .capturePayment({
// amount: 100,
// payment_id: "pay-id-1",
// })
// .catch((e) => e)
//
// expect(error.message).toEqual(
// "The payment: pay-id-1 has been canceled."
// )
// })
})
describe("refund", () => {
it("should refund a payments in bulk successfully", async () => {
await service.capturePayment({
amount: 100,
payment_id: "pay-id-1",
})
it("should refund a payments successfully", async () => {
await service.capturePayment({
amount: 100,
payment_id: "pay-id-2",
})
const refundedPayment = await service.refundPayment([
{
amount: 100,
payment_id: "pay-id-1",
},
{
amount: 100,
payment_id: "pay-id-2",
},
])
const refundedPayment = await service.refundPayment({
amount: 100,
payment_id: "pay-id-2",
})
expect(refundedPayment).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "pay-id-1",
amount: 100,
refunds: [
expect.objectContaining({
created_by: null,
amount: 100,
}),
],
// captured_amount: 100,
// refunded_amount: 100,
}),
expect.objectContaining({
id: "pay-id-2",
amount: 100,
refunds: [
expect.objectContaining({
created_by: null,
amount: 100,
}),
],
// captured_amount: 100,
// refunded_amount: 100,
}),
])
expect.objectContaining({
id: "pay-id-2",
amount: 100,
refunds: [
expect.objectContaining({
created_by: null,
amount: 100,
}),
],
// captured_amount: 100,
// refunded_amount: 100,
})
)
})
@@ -582,5 +705,31 @@ describe("Payment Module Service", () => {
// )
// })
})
describe("cancel", () => {
it("should cancel a payment", async () => {
const payment = await service.cancelPayment("pay-id-2")
expect(payment).toEqual(
expect.objectContaining({
id: "pay-id-2",
canceled_at: expect.any(Date),
})
)
})
// TODO: revisit when totals are implemented
// it("should throw if trying to cancel a captured payment", async () => {
// await service.capturePayment({ payment_id: "pay-id-2", amount: 100 })
//
// const error = await service
// .cancelPayment("pay-id-2")
// .catch((e) => e.message)
//
// expect(error).toEqual(
// "Cannot cancel a payment: pay-id-2 that has been captured."
// )
// })
})
})
})

View File

@@ -0,0 +1,10 @@
import { IPaymentModuleService, LoaderOptions } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
export default async ({ container }: LoaderOptions): Promise<void> => {
const paymentModuleService: IPaymentModuleService = container.resolve(
ModuleRegistrationName.PAYMENT
)
await paymentModuleService.createProvidersOnLoad()
}

View File

@@ -1,4 +1,4 @@
export * from "./connection"
export * from "./container"
export * from "./providers"
export * from "./defaults"

View File

@@ -3,9 +3,11 @@ import { moduleProviderLoader } from "@medusajs/modules-sdk"
import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types"
import { Lifetime, asFunction } from "awilix"
import * as providers from "../providers"
const registrationFn = async (klass, container, pluginOptions) => {
container.register({
[`payment_provider_${klass.prototype}`]: asFunction(
[`pp_${klass.identifier}`]: asFunction(
(cradle) => new klass(cradle, pluginOptions),
{
lifetime: klass.LIFE_TIME || Lifetime.SINGLETON,
@@ -30,12 +32,14 @@ export default async ({
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) & { providers: ModuleProvider[] }
>): Promise<void> => {
const pluginProviders =
options?.providers?.filter((provider) => provider.resolve) || []
// Local providers
for (const provider of Object.values(providers)) {
await registrationFn(provider, container, {})
}
await moduleProviderLoader({
container,
providers: pluginProviders,
providers: options?.providers || [],
registerServiceFn: registrationFn,
})
}

View File

@@ -43,19 +43,21 @@ export default class PaymentCollection {
})
amount: number
@Property({
columnType: "numeric",
nullable: true,
serializer: optionalNumericSerializer,
})
authorized_amount: number | null = null
// TODO: make this computed properties
@Property({
columnType: "numeric",
nullable: true,
serializer: optionalNumericSerializer,
})
refunded_amount: number | null = null
// @Property({
// columnType: "numeric",
// nullable: true,
// serializer: optionalNumericSerializer,
// })
// authorized_amount: number | null = null
//
// @Property({
// columnType: "numeric",
// nullable: true,
// serializer: optionalNumericSerializer,
// })
// refunded_amount: number | null = null
@Property({ columnType: "text", index: "IDX_payment_collection_region_id" })
region_id: string

View File

@@ -16,7 +16,7 @@ import Payment from "./payment"
@Entity({ tableName: "payment_session" })
export default class PaymentSession {
[OptionalProps]?: "status"
[OptionalProps]?: "status" | "data"
@PrimaryKey({ columnType: "text" })
id: string
@@ -33,8 +33,8 @@ export default class PaymentSession {
@Property({ columnType: "text" })
provider_id: string
@Property({ columnType: "jsonb", nullable: true })
data: Record<string, unknown> | null = null
@Property({ columnType: "jsonb" })
data: Record<string, unknown> = {}
@Enum({
items: () => PaymentSessionStatus,

View File

@@ -5,6 +5,7 @@ import { PaymentModuleService } from "@services"
import loadConnection from "./loaders/connection"
import loadContainer from "./loaders/container"
import loadProviders from "./loaders/providers"
import loadDefaults from "./loaders/defaults"
import { Modules } from "@medusajs/modules-sdk"
import { ModulesSdkUtils } from "@medusajs/utils"
@@ -25,7 +26,12 @@ export const revertMigration = ModulesSdkUtils.buildRevertMigrationScript(
)
const service = PaymentModuleService
const loaders = [loadContainer, loadConnection, loadProviders] as any
const loaders = [
loadContainer,
loadConnection,
loadProviders,
loadDefaults,
] as any
export const moduleDefinition: ModuleExports = {
service,

View File

@@ -0,0 +1 @@
export { default as SystemPaymentProvider } from "./system"

View File

@@ -0,0 +1,71 @@
import {
PaymentProviderContext,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
} from "@medusajs/types"
import { AbstractPaymentProvider } from "@medusajs/utils"
export class SystemProviderService extends AbstractPaymentProvider {
static identifier = "system"
async getStatus(_): Promise<string> {
return "authorized"
}
async getPaymentData(_): Promise<Record<string, unknown>> {
return {}
}
async initiatePayment(
context: PaymentProviderContext
): Promise<PaymentProviderSessionResponse> {
return { data: {} }
}
async getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<PaymentSessionStatus> {
throw new Error("Method not implemented.")
}
async retrievePayment(
paymentSessionData: Record<string, unknown>
): Promise<Record<string, unknown> | PaymentProviderError> {
return {}
}
async authorizePayment(_): Promise<
| PaymentProviderError
| {
status: PaymentSessionStatus
data: PaymentProviderSessionResponse["data"]
}
> {
return { data: {}, status: PaymentSessionStatus.AUTHORIZED }
}
async updatePayment(
_
): Promise<PaymentProviderError | PaymentProviderSessionResponse> {
return { data: {} } as PaymentProviderSessionResponse
}
async deletePayment(_): Promise<Record<string, unknown>> {
return {}
}
async capturePayment(_): Promise<Record<string, unknown>> {
return {}
}
async refundPayment(_): Promise<Record<string, unknown>> {
return {}
}
async cancelPayment(_): Promise<Record<string, unknown>> {
return {}
}
}
export default SystemProviderService

View File

@@ -1 +1,2 @@
export { default as PaymentModuleService } from "./payment-module"
export { default as PaymentProviderService } from "./payment-provider"

View File

@@ -4,6 +4,7 @@ import {
CreateCaptureDTO,
CreatePaymentCollectionDTO,
CreatePaymentDTO,
CreatePaymentProviderDTO,
CreatePaymentSessionDTO,
CreateRefundDTO,
DAL,
@@ -14,20 +15,18 @@ import {
PaymentCollectionDTO,
PaymentDTO,
PaymentSessionDTO,
PaymentSessionStatus,
RefundDTO,
SetPaymentSessionsDTO,
UpdatePaymentCollectionDTO,
UpdatePaymentDTO,
UpdatePaymentSessionDTO,
} from "@medusajs/types"
import {
InjectTransactionManager,
MedusaContext,
ModulesSdkUtils,
MedusaError,
InjectManager,
ModulesSdkUtils,
} from "@medusajs/utils"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import {
Capture,
Payment,
@@ -36,6 +35,9 @@ import {
Refund,
} from "@models"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import PaymentProviderService from "./payment-provider"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
paymentService: ModulesSdkTypes.InternalModuleService<any>
@@ -43,9 +45,10 @@ type InjectedDependencies = {
refundService: ModulesSdkTypes.InternalModuleService<any>
paymentSessionService: ModulesSdkTypes.InternalModuleService<any>
paymentCollectionService: ModulesSdkTypes.InternalModuleService<any>
paymentProviderService: PaymentProviderService
}
const generateMethodForModels = [PaymentCollection, PaymentSession]
const generateMethodForModels = [PaymentCollection, Payment]
export default class PaymentModuleService<
TPaymentCollection extends PaymentCollection = PaymentCollection,
@@ -74,6 +77,7 @@ export default class PaymentModuleService<
protected refundService_: ModulesSdkTypes.InternalModuleService<TRefund>
protected paymentSessionService_: ModulesSdkTypes.InternalModuleService<TPaymentSession>
protected paymentCollectionService_: ModulesSdkTypes.InternalModuleService<TPaymentCollection>
protected paymentProviderService_: PaymentProviderService
constructor(
{
@@ -82,6 +86,7 @@ export default class PaymentModuleService<
captureService,
refundService,
paymentSessionService,
paymentProviderService,
paymentCollectionService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
@@ -95,6 +100,7 @@ export default class PaymentModuleService<
this.captureService_ = captureService
this.paymentService_ = paymentService
this.paymentSessionService_ = paymentSessionService
this.paymentProviderService_ = paymentProviderService
this.paymentCollectionService_ = paymentCollectionService
}
@@ -102,18 +108,18 @@ export default class PaymentModuleService<
return joinerConfig
}
createPaymentCollection(
createPaymentCollections(
data: CreatePaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
createPaymentCollection(
createPaymentCollections(
data: CreatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
@InjectTransactionManager("baseRepository_")
async createPaymentCollection(
async createPaymentCollections(
data: CreatePaymentCollectionDTO | CreatePaymentCollectionDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
@@ -132,17 +138,17 @@ export default class PaymentModuleService<
)
}
updatePaymentCollection(
updatePaymentCollections(
data: UpdatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
updatePaymentCollection(
updatePaymentCollections(
data: UpdatePaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
@InjectTransactionManager("baseRepository_")
async updatePaymentCollection(
async updatePaymentCollections(
data: UpdatePaymentCollectionDTO | UpdatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
@@ -160,305 +166,361 @@ export default class PaymentModuleService<
)
}
createPayment(
data: CreatePaymentDTO,
completePaymentCollections(
paymentCollectionId: string,
sharedContext?: Context
): Promise<PaymentDTO>
createPayment(
data: CreatePaymentDTO[],
): Promise<PaymentCollectionDTO>
completePaymentCollections(
paymentCollectionId: string[],
sharedContext?: Context
): Promise<PaymentDTO[]>
): Promise<PaymentCollectionDTO[]>
@InjectTransactionManager("baseRepository_")
async createPayment(
data: CreatePaymentDTO | CreatePaymentDTO[],
async completePaymentCollections(
paymentCollectionId: string | string[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO | PaymentDTO[]> {
let input = Array.isArray(data) ? data : [data]
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
const input = Array.isArray(paymentCollectionId)
? paymentCollectionId.map((id) => ({
id,
completed_at: new Date(),
}))
: [{ id: paymentCollectionId, completed_at: new Date() }]
input = input.map((inputData) => ({
payment_collection: inputData.payment_collection_id,
payment_session: inputData.payment_session_id,
...inputData,
}))
// TODO: what checks should be done here? e.g. captured_amount === amount?
const payments = await this.paymentService_.create(input, sharedContext)
return await this.baseRepository_.serialize<PaymentDTO[]>(
Array.isArray(data) ? payments : payments[0],
{
populate: true,
}
const updated = await this.paymentCollectionService_.update(
input,
sharedContext
)
}
updatePayment(
data: UpdatePaymentDTO,
sharedContext?: Context | undefined
): Promise<PaymentDTO>
updatePayment(
data: UpdatePaymentDTO[],
sharedContext?: Context | undefined
): Promise<PaymentDTO[]>
@InjectTransactionManager("baseRepository_")
async updatePayment(
data: UpdatePaymentDTO | UpdatePaymentDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO | PaymentDTO[]> {
const input = Array.isArray(data) ? data : [data]
const result = await this.paymentService_.update(input, sharedContext)
return await this.baseRepository_.serialize<PaymentDTO[]>(
Array.isArray(data) ? result : result[0],
{
populate: true,
}
)
}
capturePayment(
data: CreateCaptureDTO,
sharedContext?: Context
): Promise<PaymentDTO>
capturePayment(
data: CreateCaptureDTO[],
sharedContext?: Context
): Promise<PaymentDTO[]>
@InjectManager("baseRepository_")
async capturePayment(
data: CreateCaptureDTO | CreateCaptureDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PaymentDTO | PaymentDTO[]> {
const input = Array.isArray(data) ? data : [data]
const payments = await this.capturePaymentBulk_(input, sharedContext)
return await this.baseRepository_.serialize(
Array.isArray(data) ? payments : payments[0],
Array.isArray(paymentCollectionId) ? updated : updated[0],
{ populate: true }
)
}
@InjectTransactionManager("baseRepository_")
protected async capturePaymentBulk_(
data: CreateCaptureDTO[],
@MedusaContext() sharedContext?: Context
): Promise<Payment[]> {
let payments = await this.paymentService_.list(
{ id: data.map((d) => d.payment_id) },
{},
sharedContext
)
const inputMap = new Map(data.map((d) => [d.payment_id, d]))
for (const payment of payments) {
const input = inputMap.get(payment.id)!
if (payment.captured_at) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The payment is already fully captured."
)
}
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged
// if (payment.captured_amount + input.amount > payment.authorized_amount) {
// throw new MedusaError(
// MedusaError.Types.INVALID_DATA,
// `Total captured amount for payment: ${payment.id} exceeds authorized amount.`
// )
// }
}
await this.captureService_.create(
data.map((d) => ({
payment: d.payment_id,
amount: d.amount,
captured_by: d.captured_by,
})),
sharedContext
)
let fullyCapturedPaymentsId: string[] = []
for (const payment of payments) {
const input = inputMap.get(payment.id)!
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged
// if (payment.captured_amount + input.amount === payment.amount) {
// fullyCapturedPaymentsId.push(payment.id)
// }
}
if (fullyCapturedPaymentsId.length) {
await this.paymentService_.update(
fullyCapturedPaymentsId.map((id) => ({ id, captured_at: new Date() })),
sharedContext
)
}
// TODO: set PaymentCollection status if fully captured
return await this.paymentService_.list(
{ id: data.map((d) => d.payment_id) },
{
relations: ["captures"],
},
sharedContext
)
}
refundPayment(
data: CreateRefundDTO,
sharedContext?: Context
): Promise<PaymentDTO>
refundPayment(
data: CreateRefundDTO[],
sharedContext?: Context
): Promise<PaymentDTO[]>
@InjectManager("baseRepository_")
async refundPayment(
data: CreateRefundDTO | CreateRefundDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO | PaymentDTO[]> {
const input = Array.isArray(data) ? data : [data]
const payments = await this.refundPaymentBulk_(input, sharedContext)
return await this.baseRepository_.serialize(
Array.isArray(data) ? payments : payments[0],
{ populate: true }
)
}
@InjectTransactionManager("baseRepository_")
async refundPaymentBulk_(
data: CreateRefundDTO[],
@MedusaContext() sharedContext?: Context
): Promise<Payment[]> {
const payments = await this.paymentService_.list(
{ id: data.map(({ payment_id }) => payment_id) },
{},
sharedContext
)
const inputMap = new Map(data.map((d) => [d.payment_id, d]))
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged
// for (const payment of payments) {
// const input = inputMap.get(payment.id)!
// 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.`
// )
// }
// }
await this.refundService_.create(
data.map((d) => ({
payment: d.payment_id,
amount: d.amount,
captured_by: d.created_by,
})),
sharedContext
)
return await this.paymentService_.list(
{ id: data.map(({ payment_id }) => payment_id) },
{
relations: ["refunds"],
},
sharedContext
)
}
createPaymentSession(
paymentCollectionId: string,
data: CreatePaymentSessionDTO,
sharedContext?: Context | undefined
): Promise<PaymentCollectionDTO>
createPaymentSession(
paymentCollectionId: string,
data: CreatePaymentSessionDTO[],
sharedContext?: Context | undefined
): Promise<PaymentCollectionDTO>
@InjectTransactionManager("baseRepository_")
async createPaymentSession(
paymentCollectionId: string,
data: CreatePaymentSessionDTO | CreatePaymentSessionDTO[],
data: CreatePaymentSessionDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollectionDTO> {
let input = Array.isArray(data) ? data : [data]
): Promise<PaymentSessionDTO> {
const sessionData = await this.paymentProviderService_.createSession(
data.provider_id,
data.providerContext
)
input = input.map((inputData) => ({
payment_collection: paymentCollectionId,
...inputData,
}))
await this.paymentSessionService_.create(input, sharedContext)
return await this.retrievePaymentCollection(
paymentCollectionId,
const created = await this.paymentSessionService_.create(
{
relations: ["payment_sessions"],
provider_id: data.provider_id,
amount: data.providerContext.amount,
currency_code: data.providerContext.currency_code,
payment_collection: paymentCollectionId,
data: sessionData,
},
sharedContext
)
return await this.baseRepository_.serialize(created, { populate: true })
}
@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 sessionData = await this.paymentProviderService_.updateSession(
session.provider_id,
data.providerContext
)
const updated = await this.paymentSessionService_.update(
{
id: session.id,
amount: data.providerContext.amount,
currency_code: data.providerContext.currency_code,
data: sessionData,
},
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"],
relations: ["payment_collection"],
},
sharedContext
)
if (session.authorized_at) {
const payment = await this.paymentService_.retrieve(
{ session_id: session.id },
{},
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,
authorized_amount: session.amount,
payment_session: session.id,
payment_collection: session.payment_collection!.id,
provider_id: session.provider_id,
// customer_id: context.customer.id,
data,
},
sharedContext
)
return await this.retrievePayment(payment.id, {}, 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.`
)
}
if (payment.captured_at) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The payment: ${payment.id} is already fully captured.`
)
}
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged
// if (payment.captured_amount + input.amount > payment.authorized_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
)
}
/**
* TODO
*/
@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
)
authorizePaymentCollection(
paymentCollectionId: string,
sharedContext?: Context | undefined
): Promise<PaymentCollectionDTO> {
throw new Error("Method not implemented.")
}
completePaymentCollection(
paymentCollectionId: string,
sharedContext?: Context | undefined
): Promise<PaymentCollectionDTO> {
throw new Error("Method not implemented.")
// 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 })
return await this.retrievePayment(
payment.id,
{ relations: ["refunds"] },
sharedContext
)
}
cancelPayment(paymentId: string, sharedContext?: Context): Promise<PaymentDTO>
cancelPayment(
paymentId: string[],
sharedContext?: Context
): Promise<PaymentDTO[]>
@InjectTransactionManager("baseRepository_")
async cancelPayment(
paymentId: string,
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO> {
const payment = await this.paymentService_.retrieve(
paymentId,
{ select: ["id", "data", "provider_id"] },
sharedContext
)
cancelPayment(
paymentId: string | string[],
sharedContext?: Context
): Promise<PaymentDTO | PaymentDTO[]> {
throw new Error("Method not implemented.")
// 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)
}
authorizePaymentSessions(
paymentCollectionId: string,
sessionIds: string[],
sharedContext?: Context | undefined
): Promise<PaymentCollectionDTO> {
throw new Error("Method not implemented.")
}
completePaymentSessions(
paymentCollectionId: string,
sessionIds: string[],
sharedContext?: Context | undefined
): Promise<PaymentCollectionDTO> {
throw new Error("Method not implemented.")
}
setPaymentSessions(
paymentCollectionId: string,
data: SetPaymentSessionsDTO[],
sharedContext?: Context | undefined
): Promise<PaymentCollectionDTO> {
throw new Error("Method not implemented.")
async createProvidersOnLoad() {
const providersToLoad = this.__container__["payment_providers"]
const providers = await this.paymentProviderService_.list({
// @ts-ignore TODO
id: providersToLoad.map((p) => p.getIdentifier()),
})
const loadedProvidersMap = new Map(providers.map((p) => [p.id, p]))
const providersToCreate: CreatePaymentProviderDTO[] = []
for (const provider of providersToLoad) {
if (loadedProvidersMap.has(provider.getIdentifier())) {
continue
}
providersToCreate.push({
id: provider.getIdentifier(),
})
}
await this.paymentProviderService_.create(providersToCreate)
}
}

View File

@@ -0,0 +1,183 @@
import { EOL } from "os"
import { isDefined, MedusaError } from "medusa-core-utils"
import {
Context,
CreatePaymentProviderDTO,
DAL,
InternalModuleDeclaration,
IPaymentProvider,
PaymentProviderAuthorizeResponse,
PaymentProviderContext,
PaymentProviderDataInput,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
} from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
isPaymentProviderError,
MedusaContext,
} from "@medusajs/utils"
import { PaymentProvider } from "@models"
type InjectedDependencies = {
paymentProviderRepository: DAL.RepositoryService
[key: `pp_${string}`]: IPaymentProvider
}
export default class PaymentProviderService {
protected readonly container_: InjectedDependencies
protected readonly paymentProviderRepository_: DAL.RepositoryService
constructor(
container: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
this.container_ = container
this.paymentProviderRepository_ = container.paymentProviderRepository
}
@InjectTransactionManager("paymentProviderRepository_")
async create(
data: CreatePaymentProviderDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentProvider[]> {
return await this.paymentProviderRepository_.create(data, sharedContext)
}
@InjectManager("paymentProviderRepository_")
async list(
@MedusaContext() sharedContext?: Context
): Promise<PaymentProvider[]> {
return await this.paymentProviderRepository_.find(undefined, sharedContext)
}
retrieveProvider(providerId: string): IPaymentProvider {
try {
return this.container_[`pp_${providerId}`] as IPaymentProvider
} catch (e) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Could not find a payment provider with id: ${providerId}`
)
}
}
async createSession(
providerId: string,
sessionInput: PaymentProviderContext
): Promise<PaymentProviderSessionResponse["data"]> {
const provider = this.retrieveProvider(providerId)
if (
!isDefined(sessionInput.currency_code) ||
!isDefined(sessionInput.amount)
) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"`currency_code` and `amount` are required to create payment session."
)
}
const paymentResponse = await provider.initiatePayment(sessionInput)
if (isPaymentProviderError(paymentResponse)) {
this.throwPaymentProviderError(paymentResponse)
}
return (paymentResponse as PaymentProviderSessionResponse).data
}
async updateSession(
providerId: string,
sessionInput: PaymentProviderContext
): Promise<Record<string, unknown> | undefined> {
const provider = this.retrieveProvider(providerId)
const paymentResponse = await provider.updatePayment(sessionInput)
if (isPaymentProviderError(paymentResponse)) {
this.throwPaymentProviderError(paymentResponse)
}
return (paymentResponse as PaymentProviderSessionResponse)?.data
}
async deleteSession(input: PaymentProviderDataInput): Promise<void> {
const provider = this.retrieveProvider(input.provider_id)
const error = await provider.deletePayment(input.data)
if (isPaymentProviderError(error)) {
this.throwPaymentProviderError(error)
}
}
async authorizePayment(
input: PaymentProviderDataInput,
context: Record<string, unknown>
): Promise<{ data: Record<string, unknown>; status: PaymentSessionStatus }> {
const provider = this.retrieveProvider(input.provider_id)
const res = await provider.authorizePayment(input.data, context)
if (isPaymentProviderError(res)) {
this.throwPaymentProviderError(res)
}
const { data, status } = res as PaymentProviderAuthorizeResponse
return { data, status }
}
async getStatus(
input: PaymentProviderDataInput
): Promise<PaymentSessionStatus> {
const provider = this.retrieveProvider(input.provider_id)
return await provider.getPaymentStatus(input.data)
}
async capturePayment(
input: PaymentProviderDataInput
): Promise<Record<string, unknown>> {
const provider = this.retrieveProvider(input.provider_id)
const res = await provider.capturePayment(input.data)
if (isPaymentProviderError(res)) {
this.throwPaymentProviderError(res)
}
return res as Record<string, unknown>
}
async cancelPayment(input: PaymentProviderDataInput): Promise<void> {
const provider = this.retrieveProvider(input.provider_id)
const error = await provider.cancelPayment(input.data)
if (isPaymentProviderError(error)) {
this.throwPaymentProviderError(error)
}
}
async refundPayment(
input: PaymentProviderDataInput,
amount: number
): Promise<Record<string, unknown>> {
const provider = this.retrieveProvider(input.provider_id)
const res = await provider.refundPayment(input.data, amount)
if (isPaymentProviderError(res)) {
this.throwPaymentProviderError(res)
}
return res as Record<string, unknown>
}
private throwPaymentProviderError(errObj: PaymentProviderError) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`${errObj.error}${errObj.detail ? `:${EOL}${errObj.detail}` : ""}`,
errObj.code
)
}
}

View File

@@ -31,6 +31,34 @@ export enum PaymentCollectionStatus {
CANCELED = "canceled",
}
/**
* @enum
*
* The status of a payment session.
*/
export enum PaymentSessionStatus {
/**
* The payment is authorized.
*/
AUTHORIZED = "authorized",
/**
* The payment is pending.
*/
PENDING = "pending",
/**
* The payment requires an action.
*/
REQUIRES_MORE = "requires_more",
/**
* An error occurred while processing the payment.
*/
ERROR = "error",
/**
* The payment is canceled.
*/
CANCELED = "canceled",
}
export interface PaymentCollectionDTO {
/**
* The ID of the Payment Collection
@@ -255,6 +283,48 @@ export interface PaymentSessionDTO {
* The ID of the Payment Session
*/
id: string
/**
* The amount
*/
amount: number
/**
* Payment session currency
*/
currency_code: string
/**
* The ID of payment provider
*/
provider_id: string
/**
* Payment provider data
*/
data: Record<string, unknown>
/**
* The status of the payment session
*/
status: PaymentSessionStatus
/**
* When the session was authorized
*/
authorized_at?: Date
/**
* The payment collection the session is associated with
* @expandable
*/
payment_collection?: PaymentCollectionDTO
/**
* The payment created from the session
* @expandable
*/
payment?: PaymentDTO
}
export interface PaymentProviderDTO {

View File

@@ -1,4 +1,4 @@
export * from "./common"
export * from "./mutations"
export * from "./provider"
export * from "./service"

View File

@@ -1,4 +1,5 @@
import { PaymentCollectionStatus } from "./common"
import { PaymentProviderContext } from "./provider"
/**
* Payment Collection
@@ -26,6 +27,7 @@ export interface UpdatePaymentCollectionDTO
export interface CreatePaymentDTO {
amount: number
currency_code: string
provider_id: string
data: Record<string, unknown>
@@ -46,8 +48,6 @@ export interface UpdatePaymentDTO {
order_id?: string
order_edit_id?: string
customer_id?: string
data?: Record<string, unknown>
}
export interface CreateCaptureDTO {
@@ -69,17 +69,19 @@ export interface CreateRefundDTO {
*/
export interface CreatePaymentSessionDTO {
amount: number
currency_code: string
provider_id: string
cart_id?: string
resource_id?: string
customer_id?: string
providerContext: PaymentProviderContext
}
export interface SetPaymentSessionsDTO {
provider_id: string
amount: number
session_id?: string
export interface UpdatePaymentSessionDTO {
id: string
providerContext: PaymentProviderContext
}
/**
* Payment Provider
*/
export interface CreatePaymentProviderDTO {
id: string
is_enabled?: boolean
}

View File

@@ -0,0 +1,212 @@
import { PaymentSessionStatus } from "./common"
/**
* @interface
*
* A payment's context.
*/
export type PaymentProviderContext = {
/**
* The payment's billing address.
*/
billing_address?: Record<string, unknown> | null // TODO: revisit types
/**
* The customer's email.
*/
email?: string
/**
* The selected currency code.
*/
currency_code: string
/**
* The payment's amount.
*/
amount: number
/**
* The ID of the resource the payment is associated with. For example, the cart's ID.
*/
resource_id: string
/**
* The customer associated with this payment.
*/
customer?: Record<string, unknown> // TODO: type
/**
* The context.
*/
context: Record<string, unknown>
/**
* If the payment session hasn't been created or initiated yet, it'll be an empty object.
* If the payment session exists, it'll be the value of the payment session's `data` field.
*/
payment_session_data: Record<string, unknown>
}
/**
* @interface
*
* The response of operations on a payment.
*/
export type PaymentProviderSessionResponse = {
/**
* The data to be stored in the `data` field of the Payment Session to be created.
* The `data` field is useful to hold any data required by the third-party provider to process the payment or retrieve its details at a later point.
*/
data: Record<string, unknown>
}
export type PaymentProviderAuthorizeResponse = {
/**
* The status of the payment, which will be stored in the payment session's `status` field.
*/
status: PaymentSessionStatus
/**
* The `data` to be stored in the payment session's `data` field.
*/
data: PaymentProviderSessionResponse["data"]
}
export type PaymentProviderDataInput = {
provider_id: string
data: Record<string, unknown>
}
/**
* An object that is returned in case of an error.
*/
export interface PaymentProviderError {
/**
* The error message
*/
error: string
/**
* The error code.
*/
code?: string
/**
* Any additional helpful details.
*/
detail?: any
}
export interface IPaymentProvider {
/**
* @ignore
*
* Return a unique identifier to retrieve the payment plugin provider
*/
getIdentifier(): string
/**
* Make calls to the third-party provider to initialize the payment. For example, in Stripe this method is used to create a Payment Intent for the customer.
*
* @param {PaymentProviderContext} context - The context of the payment.
* @returns {Promise<PaymentProviderError | PaymentProviderSessionResponse>} Either the payment's data or an error object.
*/
initiatePayment(
context: PaymentProviderContext
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
/**
* This method is used to update the payment session.
*
* @param {PaymentProviderContext} context - The context of the payment.
* @returns {Promise<PaymentProviderError | PaymentProviderSessionResponse | void>} Either the payment's data or an error object.
*/
updatePayment(
context: PaymentProviderContext
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
/**
* This method is used to perform any actions necessary before a Payment Session is deleted. The Payment Session is deleted in one of the following cases:
*
* @param {Record<string, unknown>} paymentSessionData - The `data` field of the Payment Session.
* @returns Either an error object or an empty object.
*/
deletePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
/**
* This method is used to authorize payment using the Payment Session.
* You can interact with a third-party provider and perform any actions necessary to authorize the payment.
*
* The payment authorization might require additional action from the customer before it is declared authorized. Once that additional action is performed,
* the `authorizePayment` method will be called again to validate that the payment is now fully authorized. So, make sure to implement it for this case as well, if necessary.
*
* :::note
*
* The payment authorization status is determined using the {@link getPaymentStatus} method. If the status is `requires_more`, then it means additional actions are required
* from the customer.
*
* :::
*
* @param {Record<string, unknown>} paymentSessionData - The `data` field of the payment session.
* @param {Record<string, unknown>} context - The context of the authorization.
* @returns The authorization details or an error object.
*/
authorizePayment(
paymentSessionData: Record<string, unknown>,
context: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderAuthorizeResponse>
/**
* This method is used to capture the payment amount. This is typically triggered manually by the store operator from the admin.
*
* You can utilize this method to interact with the third-party provider and perform any actions necessary to capture the payment.
*
* @param {Record<string, unknown>} paymentSessionData - The `data` field of the Payment for its first parameter.
* @returns Either an error object or a value that's stored in the `data` field of the Payment.
*/
capturePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
/**
* This method is used to refund a payment. This is typically triggered manually by the store operator from the admin. The refund amount might be the total amount or part of it.
*
* You can utilize this method to interact with the third-party provider and perform any actions necessary to refund the payment.
*
* @param {Record<string, unknown>} paymentSessionData - The `data` field of a Payment.
* @param {number} refundAmount - the amount to refund.
* @returns Either an error object or a value that's stored in the `data` field of the Payment.
*/
refundPayment(
paymentSessionData: Record<string, unknown>,
refundAmount: number
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
/**
* This method is used to provide a uniform way of retrieving the payment information from the third-party provider.
* For example, in Stripes Payment Provider this method is used to retrieve the payment intent details from Stripe.
*
* @param {Record<string, unknown>} paymentSessionData -
* The `data` field of a Payment Session. Make sure to store in the `data` field any necessary data that would allow you to retrieve the payment data from the third-party provider.
* @returns {Promise<PaymentProviderError | PaymentProviderSessionResponse["session_data"]>} The payment's data, typically retrieved from a third-party provider.
*/
retrievePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
/**
* This method is used to cancel a payment. This method is typically triggered by one of the following situations:
*
* You can utilize this method to interact with the third-party provider and perform any actions necessary to cancel the payment.
*
* @param {Record<string, unknown>} paymentSessionData - The `data` field of the Payment.
* @returns Either an error object or a value that's stored in the `data` field of the Payment.
*/
cancelPayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
/**
* This method is used to get the status of a Payment or a Payment Session.
*
* @param {Record<string, unknown>} paymentSessionData -
* The `data` field of a Payment as a parameter. You can use this data to interact with the third-party provider to check the status of the payment if necessary.
* @returns {Promise<PaymentSessionStatus>} The status of the Payment or Payment Session.
*/
getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<PaymentSessionStatus>
}

View File

@@ -6,25 +6,26 @@ import {
CreatePaymentDTO,
CreatePaymentSessionDTO,
CreateRefundDTO,
SetPaymentSessionsDTO,
UpdatePaymentCollectionDTO,
UpdatePaymentDTO,
UpdatePaymentSessionDTO,
} from "./mutations"
import {
FilterablePaymentCollectionProps,
PaymentCollectionDTO,
PaymentDTO,
PaymentSessionDTO,
} from "./common"
import { FindConfig } from "../common"
export interface IPaymentModuleService extends IModuleService {
/* ********** PAYMENT COLLECTION ********** */
createPaymentCollection(
createPaymentCollections(
data: CreatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
createPaymentCollection(
createPaymentCollections(
data: CreatePaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
@@ -47,11 +48,11 @@ export interface IPaymentModuleService extends IModuleService {
sharedContext?: Context
): Promise<[PaymentCollectionDTO[], number]>
updatePaymentCollection(
updatePaymentCollections(
data: UpdatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
updatePaymentCollection(
updatePaymentCollections(
data: UpdatePaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
@@ -65,59 +66,14 @@ export interface IPaymentModuleService extends IModuleService {
sharedContext?: Context
): Promise<void>
authorizePaymentCollection(
completePaymentCollections(
paymentCollectionId: string,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
completePaymentCollection(
paymentCollectionId: string,
completePaymentCollections(
paymentCollectionId: string[],
sharedContext?: Context
): Promise<PaymentCollectionDTO>
/* ********** PAYMENT ********** */
createPayment(
data: CreatePaymentDTO,
sharedContext?: Context
): Promise<PaymentDTO>
createPayment(
data: CreatePaymentDTO[],
sharedContext?: Context
): Promise<PaymentDTO[]>
capturePayment(
data: CreateCaptureDTO,
sharedContext?: Context
): Promise<PaymentDTO>
capturePayment(
data: CreateCaptureDTO[],
sharedContext?: Context
): Promise<PaymentDTO[]>
refundPayment(
data: CreateRefundDTO,
sharedContext?: Context
): Promise<PaymentDTO>
refundPayment(
data: CreateRefundDTO[],
sharedContext?: Context
): Promise<PaymentDTO[]>
cancelPayment(paymentId: string, sharedContext?: Context): Promise<PaymentDTO>
cancelPayment(
paymentId: string[],
sharedContext?: Context
): Promise<PaymentDTO[]>
updatePayment(
data: UpdatePaymentDTO,
sharedContext?: Context
): Promise<PaymentDTO>
updatePayment(
data: UpdatePaymentDTO[],
sharedContext?: Context
): Promise<PaymentDTO[]>
): Promise<PaymentCollectionDTO[]>
/* ********** PAYMENT SESSION ********** */
@@ -125,28 +81,39 @@ export interface IPaymentModuleService extends IModuleService {
paymentCollectionId: string,
data: CreatePaymentSessionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
createPaymentSession(
paymentCollectionId: string,
data: CreatePaymentSessionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO>
): Promise<PaymentSessionDTO>
authorizePaymentSessions(
paymentCollectionId: string,
sessionIds: string[],
updatePaymentSession(
data: UpdatePaymentSessionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
): Promise<PaymentSessionDTO>
completePaymentSessions(
paymentCollectionId: string,
sessionIds: string[],
sharedContext?: Context
): Promise<PaymentCollectionDTO>
deletePaymentSession(id: string, sharedContext?: Context): Promise<void>
setPaymentSessions(
paymentCollectionId: string,
data: SetPaymentSessionsDTO[],
authorizePaymentSession(
id: string,
context: Record<string, unknown>,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
): Promise<PaymentDTO>
/* ********** PAYMENT ********** */
updatePayment(
data: UpdatePaymentDTO,
sharedContext?: Context
): Promise<PaymentDTO>
capturePayment(
data: CreateCaptureDTO,
sharedContext?: Context
): Promise<PaymentDTO>
refundPayment(
data: CreateRefundDTO,
sharedContext?: Context
): Promise<PaymentDTO>
cancelPayment(paymentId: string, sharedContext?: Context): Promise<PaymentDTO>
createProvidersOnLoad(): Promise<void>
}

View File

@@ -0,0 +1,127 @@
import {
IPaymentProvider,
MedusaContainer,
PaymentProviderContext,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
} from "@medusajs/types"
export abstract class AbstractPaymentProvider implements IPaymentProvider {
/**
* You can use the `constructor` of your Payment Provider to have access to different services in Medusa through [dependency injection](https://docs.medusajs.com/development/fundamentals/dependency-injection).
*
* You can also use the constructor to initialize your integration with the third-party provider. For example, if you use a client to connect to the third-party providers APIs,
* you can initialize it in the constructor and use it in other methods in the service.
*
* Additionally, if youre creating your Payment Provider as an external plugin to be installed on any Medusa backend and you want to access the options added for the plugin,
* you can access it in the constructor. The options are passed as a second parameter.
*
* @param {MedusaContainer} container - An instance of `MedusaContainer` that allows you to access other resources, such as services, in your Medusa backend through [dependency injection](https://docs.medusajs.com/development/fundamentals/dependency-injection)
* @param {Record<string, unknown>} config - If this fulfillment provider is created in a plugin, the plugin's options are passed in this parameter.
*
* @example
* ```ts
* class MyPaymentService extends AbstractPaymentProvider {
* // ...
* constructor(container, options) {
* super(container)
* // you can access options here
*
* // you can also initialize a client that
* // communicates with a third-party service.
* this.client = new Client(options)
* }
* // ...
* }
* ```
*/
protected constructor(
protected readonly container: MedusaContainer,
protected readonly config?: Record<string, unknown> // eslint-disable-next-line @typescript-eslint/no-empty-function
) {}
static _isPaymentProvider = true
static isPaymentProvider(object): boolean {
return object?.constructor?._isPaymentProvider
}
/**
* The `PaymentProvider` entity has 2 properties: `id` and `is_installed`. The `identifier` property in the payment provider service is used when the payment provider is added to the database.
*
* The value of this property is also used to reference the payment provider throughout Medusa.
* For example, it is used to [add a payment provider](https://docs.medusajs.com/api/admin#regions_postregionsregionpaymentproviders) to a region.
*
* ```ts
* class MyPaymentService extends AbstractPaymentProvider {
* static identifier = "my-payment"
* // ...
* }
* ```
*/
public static identifier: string
/**
* @ignore
*
* Return a unique identifier to retrieve the payment plugin provider
*/
public getIdentifier(): string {
const ctr = this.constructor as typeof AbstractPaymentProvider
if (!ctr.identifier) {
throw new Error(`Missing static property "identifier".`)
}
return ctr.identifier
}
abstract capturePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
abstract authorizePayment(
paymentSessionData: Record<string, unknown>,
context: Record<string, unknown>
): Promise<
| PaymentProviderError
| {
status: PaymentSessionStatus
data: PaymentProviderSessionResponse["data"]
}
>
abstract cancelPayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
abstract initiatePayment(
context: PaymentProviderContext
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
abstract deletePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
abstract getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<PaymentSessionStatus>
abstract refundPayment(
paymentSessionData: Record<string, unknown>,
refundAmount: number
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
abstract retrievePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
abstract updatePayment(
context: PaymentProviderContext
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
}
export function isPaymentProviderError(obj: any): obj is PaymentProviderError {
return obj && typeof obj === "object" && obj.error && obj.code && obj.detail
}

View File

@@ -1,2 +1,3 @@
export * from "./payment-collection"
export * from "./payment-session"
export * from "./abstract-payment-provider"