feat: Create payment sessions (#6549)

~~Opening a draft PR to discuss a couple of implementation details that we should align on~~

**What**

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

Please note, we intentionally do not want to support creating payment sessions in bulk, as this would become a mess when having to manage multiple calls to third-party providers.
This commit is contained in:
Oli Juhl authored and GitHub committed 2024-03-05 08:40:47 +00:00
1 parent 908b1dc3a2
commit 84208aafc1
30 files changed
+603 -182

No files matched your search

@@ -25,21 +25,21 @@ export const defaultPaymentSessionData = [
amount: 100,
currency_code: "usd",
provider_id: "pp_system_default",
payment_collection: "pay-col-id-1",
payment_collection_id: "pay-col-id-1",
},
{
id: "pay-sess-id-2",
amount: 100,
currency_code: "usd",
provider_id: "pp_system_default",
payment_collection: "pay-col-id-2",
payment_collection_id: "pay-col-id-2",
},
{
id: "pay-sess-id-3",
amount: 100,
currency_code: "usd",
provider_id: "pp_system_default",
payment_collection: "pay-col-id-2",
payment_collection_id: "pay-col-id-2",
},
]
@@ -48,7 +48,7 @@ export const defaultPaymentData = [
id: "pay-id-1",
amount: 100,
currency_code: "usd",
payment_collection: "pay-col-id-1",
payment_collection_id: "pay-col-id-1",
payment_session: "pay-sess-id-1",
provider_id: "pp_system_default",
authorized_amount: 100,
@@ -59,7 +59,7 @@ export const defaultPaymentData = [
amount: 100,
authorized_amount: 100,
currency_code: "usd",
payment_collection: "pay-col-id-2",
payment_collection_id: "pay-col-id-2",
payment_session: "pay-sess-id-2",
provider_id: "pp_system_default",
data: {},
@@ -21,17 +21,9 @@ moduleIntegrationTestRunner({
}: SuiteOptions<IPaymentModuleService>) => {
describe("Payment Module Service", () => {
describe("Payment Flow", () => {
beforeEach(async () => {
const repositoryManager = MikroOrmWrapper.forkManager()
await createPaymentCollections(repositoryManager)
await createPaymentSessions(repositoryManager)
await createPayments(repositoryManager)
})
it("complete payment flow successfully", async () => {
let paymentCollection = await service.createPaymentCollections({
currency_code: "USD",
currency_code: "usd",
amount: 200,
region_id: "reg_123",
})
@@ -40,11 +32,11 @@ moduleIntegrationTestRunner({
paymentCollection.id,
{
provider_id: "pp_system_default",
providerContext: {
amount: 200,
currency_code: "USD",
payment_session_data: {},
context: {},
amount: 200,
currency_code: "usd",
data: {},
context: {
extra: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
@@ -72,7 +64,7 @@ moduleIntegrationTestRunner({
expect(paymentCollection).toEqual(
expect.objectContaining({
id: expect.any(String),
currency_code: "USD",
currency_code: "usd",
amount: 200,
// TODO
// authorized_amount: 200,
@@ -83,7 +75,7 @@ moduleIntegrationTestRunner({
payment_sessions: [
expect.objectContaining({
id: expect.any(String),
currency_code: "USD",
currency_code: "usd",
amount: 200,
provider_id: "pp_system_default",
status: "authorized",
@@ -94,7 +86,7 @@ moduleIntegrationTestRunner({
expect.objectContaining({
id: expect.any(String),
amount: 200,
currency_code: "USD",
currency_code: "usd",
provider_id: "pp_system_default",
captures: [
expect.objectContaining({
@@ -336,11 +328,11 @@ moduleIntegrationTestRunner({
it("should create a payment session successfully", async () => {
await service.createPaymentSession("pay-col-id-1", {
provider_id: "pp_system_default",
providerContext: {
amount: 200,
currency_code: "usd",
payment_session_data: {},
context: {},
amount: 200,
currency_code: "usd",
data: {},
context: {
extra: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
@@ -377,11 +369,11 @@ moduleIntegrationTestRunner({
it("should update a payment session successfully", async () => {
let session = await service.createPaymentSession("pay-col-id-1", {
provider_id: "pp_system_default",
providerContext: {
amount: 200,
currency_code: "usd",
payment_session_data: {},
context: {},
amount: 200,
currency_code: "usd",
data: {},
context: {
extra: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
@@ -391,15 +383,15 @@ moduleIntegrationTestRunner({
session = await service.updatePaymentSession({
id: session.id,
providerContext: {
amount: 200,
currency_code: "eur",
amount: 200,
currency_code: "eur",
data: {},
context: {
resource_id: "res_id",
context: {},
extra: {},
customer: {},
billing_address: {},
email: "new@test.tsst",
payment_session_data: {},
},
})
@@ -424,11 +416,11 @@ moduleIntegrationTestRunner({
const session = await service.createPaymentSession(collection.id, {
provider_id: "pp_system_default",
providerContext: {
amount: 100,
currency_code: "usd",
payment_session_data: {},
context: {},
amount: 100,
currency_code: "usd",
data: {},
context: {
extra: {},
resource_id: "test",
email: "test@test.com",
billing_address: {},
@@ -447,7 +439,6 @@ moduleIntegrationTestRunner({
amount: 100,
currency_code: "usd",
provider_id: "pp_system_default",
refunds: [],
captures: [],
data: {},
@@ -458,9 +449,7 @@ moduleIntegrationTestRunner({
deleted_at: null,
captured_at: null,
canceled_at: null,
payment_collection: expect.objectContaining({
id: expect.any(String),
}),
payment_collection_id: expect.any(String),
payment_session: expect.objectContaining({
id: expect.any(String),
updated_at: expect.any(Date),
@@ -472,6 +461,10 @@ moduleIntegrationTestRunner({
data: {},
status: "authorized",
authorized_at: expect.any(Date),
payment_collection: expect.objectContaining({
id: expect.any(String),
}),
payment_collection_id: expect.any(String),
}),
})
)
+1
View File
@@ -51,6 +51,7 @@
"@medusajs/modules-sdk": "^1.12.5",
"@medusajs/types": "^1.11.9",
"@medusajs/utils": "^1.11.2",
"@medusajs/workflows-sdk": "workspace:^",
"@mikro-orm/core": "5.9.7",
"@mikro-orm/migrations": "5.9.7",
"@mikro-orm/postgresql": "5.9.7",
@@ -22,6 +22,8 @@ export class Migration20240225134525 extends Migration {
ALTER TABLE IF EXISTS "payment_provider" ADD COLUMN IF NOT EXISTS "is_enabled" BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "payment_collection_id" TEXT NOT NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "currency_code" TEXT NOT NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "authorized_at" TEXT NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "payment_authorized_at" TIMESTAMPTZ NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL;
@@ -31,6 +33,7 @@ export class Migration20240225134525 extends Migration {
ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "provider_id" TEXT NOT NULL;
ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL;
ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL;
ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "payment_session_id" TEXT NOT NULL;
ALTER TABLE IF EXISTS "refund" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL;
ALTER TABLE IF EXISTS "refund" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL;
@@ -176,7 +179,7 @@ export class Migration20240225134525 extends Migration {
"captured_at" TIMESTAMPTZ NULL,
"canceled_at" TIMESTAMPTZ NULL,
"payment_collection_id" TEXT NOT NULL,
"session_id" TEXT NOT NULL,
"payment_session_id" TEXT NOT NULL,
"metadata" JSONB NULL,
CONSTRAINT "payment_pkey" PRIMARY KEY ("id")
);
@@ -84,12 +84,12 @@ export default class PaymentCollection {
payment_providers = new Collection<PaymentProvider>(this)
@OneToMany(() => PaymentSession, (ps) => ps.payment_collection, {
cascade: [Cascade.REMOVE],
cascade: [Cascade.PERSIST, "soft-remove"] as any,
})
payment_sessions = new Collection<PaymentSession>(this)
@OneToMany(() => Payment, (payment) => payment.payment_collection, {
cascade: [Cascade.REMOVE],
cascade: [Cascade.PERSIST, "soft-remove"] as any,
})
payments = new Collection<Payment>(this)
@@ -54,19 +54,24 @@ export default class PaymentSession {
})
authorized_at: Date | null = null
@ManyToOne(() => PaymentCollection, {
persist: false,
})
payment_collection: PaymentCollection
@ManyToOne({
entity: () => PaymentCollection,
columnType: "text",
index: "IDX_payment_session_payment_collection_id",
fieldName: "payment_collection_id",
onDelete: "cascade",
mapToPk: true,
})
payment_collection!: PaymentCollection
payment_collection_id: string
@OneToOne({
entity: () => Payment,
mappedBy: (payment) => payment.payment_session,
cascade: ["soft-remove"] as any,
nullable: true,
mappedBy: "payment_session",
})
payment?: Payment | null
+12 -4
View File
@@ -109,18 +109,26 @@ export default class Payment {
captures = new Collection<Capture>(this)
@ManyToOne({
entity: () => PaymentCollection,
persist: false,
})
payment_collection: PaymentCollection
@ManyToOne({
entity: () => PaymentCollection,
columnType: "text",
index: "IDX_payment_payment_collection_id",
fieldName: "payment_collection_id",
onDelete: "cascade",
mapToPk: true,
})
payment_collection!: PaymentCollection
payment_collection_id: string
@OneToOne({
owner: true,
fieldName: "session_id",
fieldName: "payment_session_id",
index: "IDX_payment_payment_session_id",
})
payment_session!: PaymentSession
payment_session: PaymentSession
/** COMPUTED PROPERTIES START **/
+2 -2
View File
@@ -1,5 +1,5 @@
import {
PaymentProviderContext,
CreatePaymentProviderSession,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
@@ -21,7 +21,7 @@ export class SystemProviderService extends AbstractPaymentProvider {
}
async initiatePayment(
context: PaymentProviderContext
context: CreatePaymentProviderSession
): Promise<PaymentProviderSessionResponse> {
return { data: {} }
}
+65 -41
View File
@@ -22,6 +22,7 @@ import {
UpdatePaymentSessionDTO,
} from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
MedusaError,
@@ -35,7 +36,6 @@ import {
PaymentSession,
Refund,
} from "@models"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import PaymentProviderService from "./payment-provider"
@@ -49,7 +49,7 @@ type InjectedDependencies = {
paymentProviderService: PaymentProviderService
}
const generateMethodForModels = [PaymentCollection, Payment]
const generateMethodForModels = [PaymentCollection, Payment, PaymentSession]
export default class PaymentModuleService<
TPaymentCollection extends PaymentCollection = PaymentCollection,
@@ -201,44 +201,67 @@ export default class PaymentModuleService<
)
}
@InjectTransactionManager("baseRepository_")
@InjectManager("baseRepository_")
async createPaymentSession(
paymentCollectionId: string,
input: CreatePaymentSessionDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentSessionDTO> {
let paymentSession: PaymentSession
try {
const providerSessionSession =
await this.paymentProviderService_.createSession(input.provider_id, {
context: input.context ?? {},
amount: input.amount,
currency_code: input.currency_code,
})
input.data = {
...input.data,
...providerSessionSession,
}
paymentSession = await this.createPaymentSession_(
paymentCollectionId,
input,
sharedContext
)
} catch (error) {
// In case the session is created at the provider, but fails to be created in Medusa,
// we catch the error and delete the session at the provider and rethrow.
await this.paymentProviderService_.deleteSession({
provider_id: input.provider_id,
data: input.data,
})
throw error
}
return await this.baseRepository_.serialize(paymentSession, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async createPaymentSession_(
paymentCollectionId: string,
data: CreatePaymentSessionDTO,
@MedusaContext() sharedContext?: Context
): Promise<PaymentSessionDTO> {
const created = await this.paymentSessionService_.create(
): Promise<PaymentSession> {
const paymentSession = await this.paymentSessionService_.create(
{
payment_collection_id: paymentCollectionId,
provider_id: data.provider_id,
amount: data.providerContext.amount,
currency_code: data.providerContext.currency_code,
payment_collection: paymentCollectionId,
amount: data.amount,
currency_code: data.currency_code,
context: data.context,
data: data.data,
},
sharedContext
)
try {
const sessionData = await this.paymentProviderService_.createSession(
data.provider_id,
{
...data.providerContext,
resource_id: created.id,
}
)
await this.paymentSessionService_.update(
{
id: created.id,
data: sessionData,
},
sharedContext
)
return await this.baseRepository_.serialize(created, { populate: true })
} catch (e) {
await this.paymentSessionService_.delete([created.id], sharedContext)
throw e
}
return paymentSession
}
@InjectTransactionManager("baseRepository_")
@@ -252,17 +275,12 @@ export default class PaymentModuleService<
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,
amount: data.amount,
currency_code: data.currency_code,
data: data.data,
},
sharedContext
)
@@ -298,8 +316,14 @@ export default class PaymentModuleService<
const session = await this.paymentSessionService_.retrieve(
id,
{
select: ["id", "data", "provider_id", "amount", "currency_code"],
relations: ["payment_collection"],
select: [
"id",
"data",
"provider_id",
"amount",
"currency_code",
"payment_collection_id",
],
},
sharedContext
)
@@ -348,7 +372,7 @@ export default class PaymentModuleService<
amount: session.amount,
currency_code: session.currency_code,
payment_session: session.id,
payment_collection: session.payment_collection!.id,
payment_collection_id: session.payment_collection_id,
provider_id: session.provider_id,
// customer_id: context.customer.id,
data,
@@ -1,18 +1,17 @@
import { EOL } from "os"
import { isDefined, MedusaError } from "medusa-core-utils"
import {
Context,
CreatePaymentProviderDTO,
CreatePaymentProviderSession,
DAL,
InternalModuleDeclaration,
IPaymentProvider,
PaymentProviderAuthorizeResponse,
PaymentProviderContext,
PaymentProviderDataInput,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
ProviderWebhookPayload,
UpdatePaymentProviderSession,
WebhookActionResult,
} from "@medusajs/types"
import {
@@ -21,8 +20,9 @@ import {
isPaymentProviderError,
MedusaContext,
} from "@medusajs/utils"
import { PaymentProvider } from "@models"
import { MedusaError } from "medusa-core-utils"
import { EOL } from "os"
type InjectedDependencies = {
paymentProviderRepository: DAL.RepositoryService
@@ -70,20 +70,10 @@ export default class PaymentProviderService {
async createSession(
providerId: string,
sessionInput: PaymentProviderContext
sessionInput: CreatePaymentProviderSession
): 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)) {
@@ -95,7 +85,7 @@ export default class PaymentProviderService {
async updateSession(
providerId: string,
sessionInput: PaymentProviderContext
sessionInput: UpdatePaymentProviderSession
): Promise<Record<string, unknown> | undefined> {
const provider = this.retrieveProvider(providerId)