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
2024-03-05 09:40:47 +01:00
committed by GitHub
parent 908b1dc3a2
commit 84208aafc1
30 changed files with 603 additions and 182 deletions

View File

@@ -0,0 +1,142 @@
import {
createPaymentSessionsWorkflow,
createPaymentSessionsWorkflowId,
} from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPaymentModuleService, IRegionModuleService } from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app"
import { getContainer } from "../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../environment-helpers/use-db"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("Carts workflows", () => {
let dbConnection
let appContainer
let shutdownServer
let paymentModule: IPaymentModuleService
let regionModule: IRegionModuleService
let remoteLink
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
paymentModule = appContainer.resolve(ModuleRegistrationName.PAYMENT)
regionModule = appContainer.resolve(ModuleRegistrationName.REGION)
remoteLink = appContainer.resolve("remoteLink")
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
describe("createPaymentSessionWorkflow", () => {
it("should create payment sessions", async () => {
const region = await regionModule.create({
currency_code: "usd",
name: "US",
})
let paymentCollection = await paymentModule.createPaymentCollections({
currency_code: "usd",
amount: 1000,
region_id: region.id,
})
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: paymentCollection.id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
paymentCollection = await paymentModule.retrievePaymentCollection(
paymentCollection.id,
{
relations: ["payment_sessions"],
}
)
expect(paymentCollection).toEqual(
expect.objectContaining({
id: paymentCollection.id,
currency_code: "usd",
amount: 1000,
region_id: region.id,
payment_sessions: expect.arrayContaining([
expect.objectContaining({
amount: 1000,
currency_code: "usd",
provider_id: "pp_system_default",
}),
]),
})
)
})
describe("compensation", () => {
it("should delete created payment collection if a subsequent step fails", async () => {
const workflow = createPaymentSessionsWorkflow(appContainer)
workflow.appendAction("throw", createPaymentSessionsWorkflowId, {
invoke: async function failStep() {
throw new Error(
`Failed to do something after creating payment sessions`
)
},
})
const region = await regionModule.create({
currency_code: "usd",
name: "US",
})
let paymentCollection = await paymentModule.createPaymentCollections({
currency_code: "usd",
amount: 1000,
region_id: region.id,
})
const { errors } = await workflow.run({
input: {
payment_collection_id: paymentCollection.id,
provider_id: "pp_system_default",
context: {},
data: {},
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(
`Failed to do something after creating payment sessions`
),
},
])
const sessions = await paymentModule.listPaymentSessions({
payment_collection_id: paymentCollection.id,
})
expect(sessions).toHaveLength(0)
})
})
})
})

View File

@@ -1,6 +1,6 @@
export * from "./cart"
export * from "./inventory"
export * from "./line-item"
export * from "./payment-collection"
export * from "./price-list"
export * from "./product"

View File

@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"

View File

@@ -0,0 +1,46 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPaymentModuleService, PaymentProviderContext } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
payment_collection_id: string
provider_id: string
amount: number
currency_code: string
context?: PaymentProviderContext
data?: Record<string, unknown>
}
export const createPaymentSessionStepId = "create-payment-session"
export const createPaymentSessionStep = createStep(
createPaymentSessionStepId,
async (input: StepInput, { container }) => {
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
const session = await service.createPaymentSession(
input.payment_collection_id,
{
provider_id: input.provider_id,
currency_code: input.currency_code,
amount: input.amount,
data: input.data ?? {},
context: input.context,
}
)
return new StepResponse(session, session.id)
},
async (createdSession, { container }) => {
if (!createdSession) {
return
}
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
await service.deletePaymentSession(createdSession)
}
)

View File

@@ -0,0 +1,3 @@
export * from "./create-payment-session"
export * from "./retrieve-payment-collection"

View File

@@ -0,0 +1,27 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
FindConfig,
IPaymentModuleService,
PaymentCollectionDTO,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
id: string
config?: FindConfig<PaymentCollectionDTO>
}
export const retrievePaymentCollectionStepId = "retrieve-payment-collection"
export const retrievePaymentCollectionStep = createStep(
retrievePaymentCollectionStepId,
async (data: StepInput, { container }) => {
const paymentModuleService = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
const paymentCollection =
await paymentModuleService.retrievePaymentCollection(data.id, data.config)
return new StepResponse(paymentCollection)
}
)

View File

@@ -0,0 +1,47 @@
import { PaymentProviderContext, PaymentSessionDTO } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import {
createPaymentSessionStep,
retrievePaymentCollectionStep,
} from "../steps"
interface WorkflowInput {
payment_collection_id: string
provider_id: string
data?: Record<string, unknown>
context?: PaymentProviderContext
}
export const createPaymentSessionsWorkflowId = "create-payment-sessions"
export const createPaymentSessionsWorkflow = createWorkflow(
createPaymentSessionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<PaymentSessionDTO> => {
const paymentCollection = retrievePaymentCollectionStep({
id: input.payment_collection_id,
config: {
select: ["id", "amount", "currency_code"],
},
})
const paymentSessionInput = transform(
{ paymentCollection, input },
(data) => {
return {
payment_collection_id: data.input.payment_collection_id,
provider_id: data.input.provider_id,
data: data.input.data,
context: data.input.context,
amount: data.paymentCollection.amount,
currency_code: data.paymentCollection.currency_code,
}
}
)
const created = createPaymentSessionStep(paymentSessionInput)
return created
}
)

View File

@@ -0,0 +1 @@
export * from "./create-payment-session"

View File

@@ -1,22 +1,19 @@
import { createCartWorkflow } from "@medusajs/core-flows"
import { CreateCartWorkflowInputDTO } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { createCartWorkflow } from "@medusajs/core-flows"
import { CreateCartWorkflowInputDTO } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { defaultStoreCartFields } from "../carts/query-config"
export const POST = async (
req: AuthenticatedMedusaRequest<CreateCartWorkflowInputDTO>,
res: MedusaResponse
) => {
const workflowInput = req.validatedBody
// If the customer is logged in, we auto-assign them to the cart
if (req.auth?.actor_id) {
workflowInput.customer_id = req.auth.actor_id
const workflowInput = {
...req.validatedBody,
customer_id: req.auth?.actor_id,
}
const { result, errors } = await createCartWorkflow(req.scope).run({

View File

@@ -0,0 +1,11 @@
import { transformBody } from "../../../../../api/middlewares"
import { MiddlewareRoute } from "../../../../../loaders/helpers/routing/types"
import { StorePostPaymentCollectionsPaymentSessionReq } from "./validators"
export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["POST"],
matcher: "/store/payment-collections/:id/payment-sessions",
middlewares: [transformBody(StorePostPaymentCollectionsPaymentSessionReq)],
},
]

View File

@@ -0,0 +1,20 @@
export const defaultStorePaymentCollectionFields = [
"id",
"currency_code",
"amount",
"payment_sessions",
"payment_sessions.id",
"payment_sessions.amount",
"payment_sessions.provider_id",
]
export const defaultStorePaymentCollectionRelations = ["payment_sessions"]
export const allowedStorePaymentCollectionRelations = ["payment_sessions"]
export const retrieveTransformQueryConfig = {
defaultFields: defaultStorePaymentCollectionFields,
defaultRelations: defaultStorePaymentCollectionRelations,
allowedRelations: allowedStorePaymentCollectionRelations,
isList: false,
}

View File

@@ -0,0 +1,52 @@
import { createPaymentSessionsWorkflow } from "@medusajs/core-flows"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../types/routing"
import { defaultStorePaymentCollectionFields } from "./query-config"
import { StorePostPaymentCollectionsPaymentSessionReq } from "./validators"
export const POST = async (
req: AuthenticatedMedusaRequest<StorePostPaymentCollectionsPaymentSessionReq>,
res: MedusaResponse
) => {
const { id } = req.params
const { context, provider_id, data } = req.body
// If the customer is logged in, we auto-assign them to the payment collection
if (req.auth?.actor_id) {
context.customer = {
...context.customer,
id: req.auth.actor_id,
}
}
const workflowInput = {
payment_collection_id: id,
provider_id: provider_id,
data,
context,
}
const { errors } = await createPaymentSessionsWorkflow(req.scope).run({
input: workflowInput as any,
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const remoteQuery = req.scope.resolve("remoteQuery")
const query = remoteQueryObjectFromString({
entryPoint: "payment_collection",
variables: { cart: { id } },
fields: defaultStorePaymentCollectionFields,
})
const [result] = await remoteQuery(query)
res.status(200).json({ payment_collection: result })
}

View File

@@ -0,0 +1,7 @@
import { PaymentProviderContext } from "@medusajs/types"
export class StorePostPaymentCollectionsPaymentSessionReq {
provider_id: string
context?: PaymentProviderContext
data?: Record<string, unknown>
}

View File

@@ -4,21 +4,22 @@ import Stripe from "stripe"
import {
MedusaContainer,
PaymentSessionStatus,
PaymentProviderContext,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
ProviderWebhookPayload,
UpdatePaymentProviderSession,
WebhookActionResult,
} from "@medusajs/types"
import {
PaymentActions,
AbstractPaymentProvider,
isPaymentProviderError,
MedusaError,
PaymentActions,
isPaymentProviderError,
} from "@medusajs/utils"
import { isDefined } from "medusa-core-utils"
import { CreatePaymentProviderSession } from "@medusajs/types"
import {
ErrorCodes,
ErrorIntentStatus,
@@ -103,26 +104,20 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeCredentials> {
}
async initiatePayment(
context: PaymentProviderContext
input: CreatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse> {
const intentRequestData = this.getPaymentIntentOptions()
const {
email,
context: cart_context,
currency_code,
amount,
resource_id,
customer,
} = context
const { email, extra, resource_id, customer } = input.context
const { currency_code, amount } = input
const description = (cart_context.payment_description ??
const description = (extra?.payment_description ??
this.options_?.payment_description) as string
const intentRequest: Stripe.PaymentIntentCreateParams = {
description,
amount: Math.round(amount),
currency: currency_code,
metadata: { resource_id },
metadata: { resource_id: resource_id ?? "Medusa Payment" },
capture_method: this.options_.capture ? "automatic" : "manual",
...intentRequestData,
}
@@ -149,9 +144,9 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeCredentials> {
intentRequest.customer = stripeCustomer.id
}
let session_data
let sessionData
try {
session_data = (await this.stripe_.paymentIntents.create(
sessionData = (await this.stripe_.paymentIntents.create(
intentRequest
)) as unknown as Record<string, unknown>
} catch (e) {
@@ -162,7 +157,7 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeCredentials> {
}
return {
data: session_data,
data: sessionData,
// TODO: REVISIT
// update_requests: customer?.metadata?.stripe_id
// ? undefined
@@ -260,13 +255,14 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeCredentials> {
}
async updatePayment(
context: PaymentProviderContext
input: UpdatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse> {
const { amount, customer, payment_session_data } = context
const stripeId = customer?.metadata?.stripe_id
const { context, data, amount } = input
if (stripeId !== payment_session_data.customer) {
const result = await this.initiatePayment(context)
const stripeId = context.customer?.metadata?.stripe_id
if (stripeId !== data.customer) {
const result = await this.initiatePayment(input)
if (isPaymentProviderError(result)) {
return this.buildError(
"An error occurred in updatePayment during the initiate of the new payment for the new customer",
@@ -276,12 +272,12 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeCredentials> {
return result
} else {
if (amount && payment_session_data.amount === Math.round(amount)) {
return { data: payment_session_data }
if (amount && data.amount === Math.round(amount)) {
return { data }
}
try {
const id = payment_session_data.id as string
const id = data.id as string
const sessionData = (await this.stripe_.paymentIntents.update(id, {
amount: Math.round(amount),
})) as unknown as PaymentProviderSessionResponse["data"]

View File

@@ -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: {},

View File

@@ -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),
}),
})
)

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",

View File

@@ -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")
);

View File

@@ -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)

View File

@@ -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

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 **/

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: {} }
}

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,

View File

@@ -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)

View File

@@ -169,6 +169,19 @@ export interface FilterablePaymentCollectionProps
updated_at?: OperatorMap<string>
}
export interface FilterablePaymentSessionProps
extends BaseFilterable<PaymentSessionDTO> {
id?: string | string[]
currency_code?: string | string[]
amount?: number | OperatorMap<number>
provider_id?: string | string[]
payment_collection_id?: string | string[]
region_id?: string | string[] | OperatorMap<string>
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
deleted_at?: OperatorMap<string>
}
/* ********** PAYMENT ********** */
export interface PaymentDTO {
/**

View File

@@ -186,9 +186,21 @@ export interface CreatePaymentSessionDTO {
*/
provider_id: string
/**
* The provider's context.
* The selected currency code.
*/
providerContext: Omit<PaymentProviderContext, "resource_id">
currency_code: string
/**
* The payment's amount.
*/
amount: number
/**
* The value of the payment session's `data` field.
*/
data: Record<string, unknown>
/**
* The payment session's context.
*/
context?: PaymentProviderContext
}
/**
@@ -199,10 +211,22 @@ export interface UpdatePaymentSessionDTO {
* The payment session's ID.
*/
id: string
/**
* The value of the payment session's `data` field.
*/
data: Record<string, unknown>
/**
* The selected currency code.
*/
currency_code: string
/**
* The payment's amount.
*/
amount: number
/**
* The payment session's context.
*/
providerContext: PaymentProviderContext
context?: PaymentProviderContext
}
/**

View File

@@ -1,6 +1,6 @@
import { PaymentSessionStatus } from "./common"
import { CustomerDTO } from "../customer"
import { AddressDTO } from "../address"
import { CustomerDTO } from "../customer"
import { PaymentSessionStatus } from "./common"
import { ProviderWebhookPayload } from "./mutations"
export type PaymentAddressDTO = Partial<AddressDTO>
@@ -43,31 +43,31 @@ export type PaymentProviderContext = {
* 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 i.e. the ID of the PaymentSession in Medusa
*/
resource_id: string
resource_id?: string
/**
* The customer associated with this payment.
*/
customer?: PaymentCustomerDTO
/**
* The context.
* The extra fields specific to the provider session.
*/
context: { payment_description?: string } & 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>
extra?: Record<string, unknown>
}
export type CreatePaymentProviderSession = {
context: PaymentProviderContext
amount: number
currency_code: string
}
export type UpdatePaymentProviderSession = {
context: PaymentProviderContext
data: Record<string, unknown>
amount: number
currency_code: string
}
/**
@@ -142,21 +142,21 @@ export interface IPaymentProvider {
/**
* 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.
* @param {CreatePaymentProviderSession} context
* @returns {Promise<PaymentProviderError | PaymentProviderSessionResponse>} Either the payment's data or an error object.
*/
initiatePayment(
context: PaymentProviderContext
data: CreatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
/**
* This method is used to update the payment session.
*
* @param {PaymentProviderContext} context - The context of the payment.
* @param {UpdatePaymentProviderSession} context
* @returns {Promise<PaymentProviderError | PaymentProviderSessionResponse | void>} Either the payment's data or an error object.
*/
updatePayment(
context: PaymentProviderContext
context: UpdatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
/**

View File

@@ -4,6 +4,7 @@ import { Context } from "../shared-context"
import {
FilterablePaymentCollectionProps,
FilterablePaymentProps,
FilterablePaymentSessionProps,
PaymentCollectionDTO,
PaymentDTO,
PaymentSessionDTO,
@@ -387,6 +388,12 @@ export interface IPaymentModuleService extends IModuleService {
*/
cancelPayment(paymentId: string, sharedContext?: Context): Promise<PaymentDTO>
listPaymentSessions(
filters?: FilterablePaymentSessionProps,
config?: FindConfig<PaymentSessionDTO>,
sharedContext?: Context
): Promise<PaymentSessionDTO[]>
/**
* This method creates providers on load.
*

View File

@@ -1,12 +1,13 @@
import {
CreatePaymentProviderSession,
IPaymentProvider,
MedusaContainer,
PaymentProviderContext,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
ProviderWebhookPayload,
WebhookActionResult,
UpdatePaymentProviderSession,
WebhookActionResult
} from "@medusajs/types"
export abstract class AbstractPaymentProvider<TConfig = Record<string, unknown>>
@@ -107,7 +108,7 @@ export abstract class AbstractPaymentProvider<TConfig = Record<string, unknown>>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
abstract initiatePayment(
context: PaymentProviderContext
context: CreatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
abstract deletePayment(
@@ -128,7 +129,7 @@ export abstract class AbstractPaymentProvider<TConfig = Record<string, unknown>>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]>
abstract updatePayment(
context: PaymentProviderContext
context: UpdatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse>
abstract getWebhookActionAndData(

View File

@@ -8642,6 +8642,7 @@ __metadata:
"@medusajs/modules-sdk": ^1.12.5
"@medusajs/types": ^1.11.9
"@medusajs/utils": ^1.11.2
"@medusajs/workflows-sdk": "workspace:^"
"@mikro-orm/cli": 5.9.7
"@mikro-orm/core": 5.9.7
"@mikro-orm/migrations": 5.9.7
@@ -9126,7 +9127,7 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/workflows-sdk@^0.1.2, @medusajs/workflows-sdk@^0.1.3, @medusajs/workflows-sdk@workspace:packages/workflows-sdk":
"@medusajs/workflows-sdk@^0.1.2, @medusajs/workflows-sdk@^0.1.3, @medusajs/workflows-sdk@workspace:^, @medusajs/workflows-sdk@workspace:packages/workflows-sdk":
version: 0.0.0-use.local
resolution: "@medusajs/workflows-sdk@workspace:packages/workflows-sdk"
dependencies: