fix(core-flows): delete existing payment session before creating new (#7751)

what:

Multiple active sessions are used for split payments. We don't currently support split payments. Until we have that implemented, this change is to ensure that whenever we create a new payment session, we delete an existing session if present. 

RESOLVES CORE-2284
This commit is contained in:
Riqwan Thamir
2024-06-18 13:51:20 +02:00
committed by GitHub
parent cfa983001b
commit 288e41856b
18 changed files with 350 additions and 135 deletions

View File

@@ -0,0 +1,65 @@
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(30000)
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => {
beforeAll(() => {})
beforeEach(async () => {
const container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
})
describe("POST /admin/payment-collections/:id/payment-sessions", () => {
let region
beforeEach(async () => {
region = (
await api.post(
"/admin/regions",
{ name: "United States", currency_code: "usd", countries: ["us"] },
adminHeaders
)
).data.region
})
it("should create a payment session", async () => {
const paymentCollection = (
await api.post(`/store/payment-collections`, {
region_id: region.id,
cart_id: "cart.id",
amount: 150,
currency_code: "usd",
})
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" }
)
// Adding a second payment session to ensure only one session gets created
const {
data: { payment_collection },
} = await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" }
)
expect(payment_collection.payment_sessions).toEqual([
expect.objectContaining({
currency_code: "usd",
provider_id: "pp_system_default",
status: "pending",
amount: 150,
}),
])
})
})
},
})

View File

@@ -27,18 +27,23 @@ medusaIntegrationTestRunner({
})
describe("createPaymentSessionWorkflow", () => {
it("should create payment sessions", async () => {
const region = await regionModule.create({
let region
let paymentCollection
beforeEach(async () => {
region = await regionModule.create({
currency_code: "usd",
name: "US",
})
let paymentCollection = await paymentModule.createPaymentCollections({
paymentCollection = await paymentModule.createPaymentCollections({
currency_code: "usd",
amount: 1000,
region_id: region.id,
})
})
it("should create payment sessions", async () => {
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: paymentCollection.id,
@@ -72,6 +77,47 @@ medusaIntegrationTestRunner({
)
})
it("should delete existing sessions when create payment sessions", async () => {
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: paymentCollection.id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
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.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)

View File

@@ -7,10 +7,8 @@ import {
transform,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import {
deletePaymentSessionStep,
updatePaymentCollectionStep,
} from "../../payment-collection"
import { updatePaymentCollectionStep } from "../../../payment-collection"
import { deletePaymentSessionsWorkflow } from "../../../payment-collection/workflows/delete-payment-sessions"
type WorklowInput = {
cart_id: string
@@ -55,10 +53,19 @@ export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
})
const cart = transform({ carts }, (data) => data.carts[0])
const deletePaymentSessionInput = transform(
{ paymentCollection: cart.payment_collection },
(data) => {
return {
ids:
data.paymentCollection?.payment_sessions?.map((ps) => ps.id) || [],
}
}
)
parallelize(
deletePaymentSessionStep({
payment_session_id: cart.payment_collection.payment_sessions?.[0].id,
deletePaymentSessionsWorkflow.runAsStep({
input: deletePaymentSessionInput,
}),
updatePaymentCollectionStep({
selector: { id: cart.payment_collection.id },

View File

@@ -1,3 +1,3 @@
export * from "../payment-collection"
export * from "./cart"
export * from "./line-item"
export * from "./payment-collection"

View File

@@ -1,46 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPaymentModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
payment_session_id?: string
}
export const deletePaymentSessionStepId = "delete-payment-session"
export const deletePaymentSessionStep = createStep(
deletePaymentSessionStepId,
async (input: StepInput, { container }) => {
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
if (!input.payment_session_id) {
return new StepResponse(void 0, null)
}
const [session] = await service.listPaymentSessions({
id: input.payment_session_id,
})
await service.deletePaymentSession(input.payment_session_id)
return new StepResponse(input.payment_session_id, session)
},
async (input, { container }) => {
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
if (!input || !input.payment_collection) {
return
}
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,
})
}
)

View File

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

View File

@@ -1,27 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -11,6 +11,7 @@ export * from "./inventory"
export * from "./invite"
export * from "./order"
export * from "./payment"
export * from "./payment-collection"
export * from "./price-list"
export * from "./pricing"
export * from "./product"

View File

@@ -0,0 +1,101 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
IPaymentModuleService,
Logger,
PaymentSessionDTO,
} from "@medusajs/types"
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
ids: string[]
}
// Note: This step should not be used alone as it doesn't consider a revert
// Use deletePaymentSessionsWorkflow instead that uses this step
export const deletePaymentSessionsStepId = "delete-payment-sessions"
export const deletePaymentSessionsStep = createStep(
deletePaymentSessionsStepId,
async (input: StepInput, { container }) => {
const { ids = [] } = input
const deleted: PaymentSessionDTO[] = []
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
if (!ids?.length) {
return new StepResponse([], null)
}
for (const id of ids) {
const select = [
"provider_id",
"currency_code",
"amount",
"data",
"context",
"payment_collection.id",
]
const [session] = await service.listPaymentSessions({ id }, { select })
// As this requires an external method call, we will try to delete as many successful calls
// as possible and pass them over to the compensation step to be recreated if any of the
// payment sessions fails to delete.
try {
await service.deletePaymentSession(id)
deleted.push(session)
} catch (e) {
logger.error(
`Encountered an error when trying to delete payment session - ${id} - ${e}`
)
}
}
return new StepResponse(
deleted.map((d) => d.id),
deleted
)
},
async (deletedPaymentSessions, { container }) => {
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
if (!deletedPaymentSessions?.length) {
return
}
for (const paymentSession of deletedPaymentSessions) {
if (!paymentSession.payment_collection?.id) {
continue
}
const payload = {
provider_id: paymentSession.provider_id,
currency_code: paymentSession.currency_code,
amount: paymentSession.amount,
data: paymentSession.data ?? {},
context: paymentSession.context,
}
// Creating a payment session also requires an external call. If we fail to recreate the
// payment step, we would have to compensate successfully even though it didn't recreate
// all the necessary sessions. We accept a level of risk here for the payment collection to
// be set in an incomplete state.
try {
await service.createPaymentSession(
paymentSession.payment_collection?.id,
payload
)
} catch (e) {
logger.error(
`Encountered an error when trying to recreate a payment session - ${payload} - ${e}`
)
}
}
}
)

View File

@@ -0,0 +1,4 @@
export * from "./create-payment-session"
export * from "./delete-payment-sessions"
export * from "./update-payment-collection"
export * from "./validate-deleted-payment-sessions"

View File

@@ -0,0 +1,25 @@
import { MedusaError } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
idsToDelete: string[]
idsDeleted: string[]
}
export const validateDeletedPaymentSessionsStepId =
"validate-deleted-payment-sessions"
export const validateDeletedPaymentSessionsStep = createStep(
validateDeletedPaymentSessionsStepId,
async (input: StepInput) => {
const { idsToDelete = [], idsDeleted = [] } = input
if (idsToDelete.length !== idsDeleted.length) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
`Could not delete all payment sessions`
)
}
return new StepResponse(void 0)
}
)

View File

@@ -0,0 +1,67 @@
import { PaymentProviderContext, PaymentSessionDTO } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
parallelize,
transform,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import { createPaymentSessionStep } from "../steps"
import { deletePaymentSessionsWorkflow } from "./delete-payment-sessions"
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 = useRemoteQueryStep({
entry_point: "payment_collection",
fields: ["id", "amount", "currency_code", "payment_sessions.*"],
variables: { id: input.payment_collection_id },
list: false,
})
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 deletePaymentSessionInput = transform(
{ paymentCollection },
(data) => {
return {
ids:
data.paymentCollection?.payment_sessions?.map((ps) => ps.id) || [],
}
}
)
// Note: We are deleting an existing active session before creating a new one
// for a payment collection as we don't support split payments at the moment.
// When we are ready to accept split payments, this along with other workflows
// need to be handled correctly
const [created] = parallelize(
createPaymentSessionStep(paymentSessionInput),
deletePaymentSessionsWorkflow.runAsStep({
input: deletePaymentSessionInput,
})
)
return created
}
)

View File

@@ -0,0 +1,24 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import {
deletePaymentSessionsStep,
validateDeletedPaymentSessionsStep,
} from "../steps"
interface WorkflowInput {
ids: string[]
}
export const deletePaymentSessionsWorkflowId = "delete-payment-sessions"
export const deletePaymentSessionsWorkflow = createWorkflow(
deletePaymentSessionsWorkflowId,
(input: WorkflowData<WorkflowInput>) => {
const idsDeleted = deletePaymentSessionsStep({ ids: input.ids })
validateDeletedPaymentSessionsStep({
idsToDelete: input.ids,
idsDeleted,
})
return idsDeleted
}
)