fix(core-flows): Refund and recreate payment session on cart complete failure (#12263)

This commit is contained in:
Frane Polić
2025-05-11 19:53:49 +02:00
committed by GitHub
parent 3fb4d5beb0
commit 5fe0e8250d
12 changed files with 967 additions and 399 deletions

View File

@@ -0,0 +1,753 @@
import {
addToCartWorkflow,
completeCartWorkflow,
createCartWorkflow,
createPaymentCollectionForCartWorkflow,
createPaymentSessionsWorkflow,
getOrderDetailWorkflow,
listShippingOptionsForCartWorkflow,
processPaymentWorkflow,
} from "@medusajs/core-flows"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import {
ICartModuleService,
ICustomerModuleService,
IFulfillmentModuleService,
IInventoryService,
IPaymentModuleService,
IPricingModuleService,
IProductModuleService,
IRegionModuleService,
ISalesChannelModuleService,
IStockLocationService,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
Modules,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
adminHeaders,
createAdminUser,
generatePublishableKey,
generateStoreHeaders,
} from "../../../../helpers/create-admin-user"
import { seedStorefrontDefaults } from "../../../../helpers/seed-storefront-defaults"
import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer"
jest.setTimeout(200000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
medusaIntegrationTestRunner({
env,
testSuite: ({ dbConnection, getContainer, api }) => {
describe("Carts workflows", () => {
let appContainer
let cartModuleService: ICartModuleService
let regionModuleService: IRegionModuleService
let scModuleService: ISalesChannelModuleService
let customerModule: ICustomerModuleService
let productModule: IProductModuleService
let pricingModule: IPricingModuleService
let paymentModule: IPaymentModuleService
let stockLocationModule: IStockLocationService
let inventoryModule: IInventoryService
let fulfillmentModule: IFulfillmentModuleService
let remoteLink, remoteQuery, query
let storeHeaders
let salesChannel
let defaultRegion
let customer, storeHeadersWithCustomer
let setPricingContextHook: any
beforeAll(async () => {
appContainer = getContainer()
cartModuleService = appContainer.resolve(Modules.CART)
regionModuleService = appContainer.resolve(Modules.REGION)
scModuleService = appContainer.resolve(Modules.SALES_CHANNEL)
customerModule = appContainer.resolve(Modules.CUSTOMER)
productModule = appContainer.resolve(Modules.PRODUCT)
pricingModule = appContainer.resolve(Modules.PRICING)
paymentModule = appContainer.resolve(Modules.PAYMENT)
fulfillmentModule = appContainer.resolve(Modules.FULFILLMENT)
inventoryModule = appContainer.resolve(Modules.INVENTORY)
stockLocationModule = appContainer.resolve(Modules.STOCK_LOCATION)
remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK)
remoteQuery = appContainer.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
query = appContainer.resolve(ContainerRegistrationKeys.QUERY)
createCartWorkflow.hooks.setPricingContext(
(input) => {
if (setPricingContextHook) {
return setPricingContextHook(input)
}
},
() => {}
)
addToCartWorkflow.hooks.setPricingContext(
(input) => {
if (setPricingContextHook) {
return setPricingContextHook(input)
}
},
() => {}
)
listShippingOptionsForCartWorkflow.hooks.setPricingContext(
(input) => {
if (setPricingContextHook) {
return setPricingContextHook(input)
}
},
() => {}
)
})
beforeEach(async () => {
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })
await createAdminUser(dbConnection, adminHeaders, appContainer)
const result = await createAuthenticatedCustomer(api, storeHeaders, {
first_name: "tony",
last_name: "stark",
email: "tony@test-industries.com",
})
customer = result.customer
storeHeadersWithCustomer = {
headers: {
...storeHeaders.headers,
authorization: `Bearer ${result.jwt}`,
},
}
const { region } = await seedStorefrontDefaults(appContainer, "dkk")
defaultRegion = region
salesChannel = (
await api.post(
"/admin/sales-channels",
{ name: "test sales channel", description: "channel" },
adminHeaders
)
).data.sales_channel
})
describe("CompleteCartWorkflow", () => {
it("should complete cart with custom item", async () => {
const salesChannel = await scModuleService.createSalesChannels({
name: "Webshop",
})
const location = await stockLocationModule.createStockLocations({
name: "Warehouse",
})
const region = await regionModuleService.createRegions({
name: "US",
currency_code: "usd",
})
let cart = await cartModuleService.createCarts({
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
})
await remoteLink.create([
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
},
])
cart = await cartModuleService.retrieveCart(cart.id, {
select: ["id", "region_id", "currency_code", "sales_channel_id"],
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
title: "Test item",
subtitle: "Test subtitle",
thumbnail: "some-url",
requires_shipping: false,
is_discountable: false,
is_tax_inclusive: false,
unit_price: 3000,
metadata: {
foo: "bar",
},
quantity: 1,
},
{
title: "zero price item",
subtitle: "zero price item",
thumbnail: "some-url",
requires_shipping: false,
is_discountable: false,
is_tax_inclusive: false,
unit_price: 0,
quantity: 1,
},
],
cart_id: cart.id,
},
})
cart = await cartModuleService.retrieveCart(cart.id, {
relations: ["items"],
})
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
},
})
const [paymentCollection] =
await paymentModule.listPaymentCollections({})
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: paymentCollection.id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
await completeCartWorkflow(appContainer).run({
input: {
id: cart.id,
},
})
const { data } = await query.graph({
entity: "cart",
filters: {
id: cart.id,
},
fields: ["id", "currency_code", "completed_at", "items.*"],
})
expect(data[0]).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
completed_at: expect.any(Date),
items: [
{
cart_id: cart.id,
compare_at_unit_price: null,
created_at: expect.any(Date),
deleted_at: null,
id: expect.any(String),
is_discountable: false,
is_giftcard: false,
is_tax_inclusive: false,
is_custom_price: true,
metadata: {
foo: "bar",
},
product_collection: null,
product_description: null,
product_handle: null,
product_id: null,
product_subtitle: null,
product_title: null,
product_type: null,
product_type_id: null,
quantity: 1,
raw_compare_at_unit_price: null,
raw_unit_price: {
precision: 20,
value: "3000",
},
requires_shipping: false,
subtitle: "Test subtitle",
thumbnail: "some-url",
title: "Test item",
unit_price: 3000,
updated_at: expect.any(Date),
variant_barcode: null,
variant_id: null,
variant_option_values: null,
variant_sku: null,
variant_title: null,
},
expect.objectContaining({
title: "zero price item",
subtitle: "zero price item",
is_custom_price: true,
unit_price: 0,
}),
],
})
)
})
it("should complete cart reserving inventory from available locations", async () => {
const salesChannel = await scModuleService.createSalesChannels({
name: "Webshop",
})
const location = await stockLocationModule.createStockLocations({
name: "Warehouse",
})
const location2 = await stockLocationModule.createStockLocations({
name: "Side Warehouse",
})
const [product] = await productModule.createProducts([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const inventoryItem = await inventoryModule.createInventoryItems({
sku: "inv-1234",
})
await inventoryModule.createInventoryLevels([
{
inventory_item_id: inventoryItem.id,
location_id: location.id,
stocked_quantity: 1,
reserved_quantity: 0,
},
])
await inventoryModule.createInventoryLevels([
{
inventory_item_id: inventoryItem.id,
location_id: location2.id,
stocked_quantity: 1,
reserved_quantity: 0,
},
])
const priceSet = await pricingModule.createPriceSets({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await pricingModule.createPricePreferences({
attribute: "currency_code",
value: "usd",
is_tax_inclusive: true,
})
await remoteLink.create([
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.PRICING]: {
price_set_id: priceSet.id,
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location2.id,
},
},
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.INVENTORY]: {
inventory_item_id: inventoryItem.id,
},
},
])
// complete 2 carts
for (let i = 1; i <= 2; i++) {
const cart = await cartModuleService.createCarts({
currency_code: "usd",
sales_channel_id: salesChannel.id,
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
requires_shipping: false,
},
],
cart_id: cart.id,
},
})
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
},
})
const [payCol] = await remoteQuery(
remoteQueryObjectFromString({
entryPoint: "cart_payment_collection",
variables: { filters: { cart_id: cart.id } },
fields: ["payment_collection_id"],
})
)
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: payCol.payment_collection_id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
await completeCartWorkflow(appContainer).run({
input: {
id: cart.id,
},
})
}
const reservations = await api.get(
`/admin/reservations`,
adminHeaders
)
const locations = reservations.data.reservations.map(
(r) => r.location_id
)
expect(locations).toEqual(
expect.arrayContaining([location.id, location2.id])
)
})
it("should complete cart when payment webhook is called first and payment has auto-capture on", async () => {
const salesChannel = await scModuleService.createSalesChannels({
name: "Webshop",
})
const location = await stockLocationModule.createStockLocations({
name: "Warehouse",
})
const [product] = await productModule.createProducts([
{
title: "Test product",
variants: [
{
title: "Test variant",
manage_inventory: false,
},
],
},
])
const priceSet = await pricingModule.createPriceSets({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await pricingModule.createPricePreferences({
attribute: "currency_code",
value: "usd",
has_tax_inclusive: true,
})
await remoteLink.create([
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.PRICING]: {
price_set_id: priceSet.id,
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
},
])
// create cart
const cart = await cartModuleService.createCarts({
currency_code: "usd",
sales_channel_id: salesChannel.id,
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
requires_shipping: false,
},
],
cart_id: cart.id,
},
})
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
},
})
const [payCol] = await remoteQuery(
remoteQueryObjectFromString({
entryPoint: "cart_payment_collection",
variables: { filters: { cart_id: cart.id } },
fields: ["payment_collection_id"],
})
)
const { result: paymentSession } =
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: payCol.payment_collection_id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
// payment webhook is triggered before complete cart workflow
await processPaymentWorkflow(appContainer).run({
input: {
action: "captured",
data: {
session_id: paymentSession.id,
amount: 3000,
},
},
})
// call complete cart workflow after
const { result: order } = await completeCartWorkflow(
appContainer
).run({
input: {
id: cart.id,
},
})
const { result: fullOrder } = await getOrderDetailWorkflow(
appContainer
).run({
input: {
fields: ["*"],
order_id: order.id,
},
})
expect(fullOrder.payment_status).toBe("captured")
expect(fullOrder.payment_collections[0].authorized_amount).toBe(3000)
expect(fullOrder.payment_collections[0].captured_amount).toBe(3000)
expect(fullOrder.payment_collections[0].status).toBe("completed")
})
it("should refund payment when payment webhook is called first and payment has auto-capture on but the completion fails", async () => {
const salesChannel = await scModuleService.createSalesChannels({
name: "Webshop",
})
const location = await stockLocationModule.createStockLocations({
name: "Warehouse",
})
const [product] = await productModule.createProducts([
{
title: "Test product",
variants: [
{
title: "Test variant",
manage_inventory: false,
},
],
},
])
const priceSet = await pricingModule.createPriceSets({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await pricingModule.createPricePreferences({
attribute: "currency_code",
value: "usd",
is_tax_inclusive: true,
})
await remoteLink.create([
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.PRICING]: {
price_set_id: priceSet.id,
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
},
])
// create cart
const cart = await cartModuleService.createCarts({
currency_code: "usd",
sales_channel_id: salesChannel.id,
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
requires_shipping: false,
},
],
cart_id: cart.id,
},
})
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
},
})
const [payCol] = await remoteQuery(
remoteQueryObjectFromString({
entryPoint: "cart_payment_collection",
variables: { filters: { cart_id: cart.id } },
fields: ["payment_collection_id"],
})
)
const { result: paymentSession } =
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: payCol.payment_collection_id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
let validateHook: Function | undefined = () => {
throw new Error("cart complete failed")
}
completeCartWorkflow.hooks.validate(() => {
if (validateHook) {
validateHook()
}
})
// payment webhook is triggered before complete cart workflow
await processPaymentWorkflow(appContainer).run({
input: {
action: "captured",
data: {
session_id: paymentSession.id,
amount: 3000,
},
},
})
validateHook = undefined
const paymentSessionQuery = await query.graph({
entity: "payment_collection",
variables: {
filters: {
id: paymentSession.payment_collection_id,
},
},
fields: [
"*",
"payment_sessions.*",
"payments.*",
"payments.captures.*",
"payments.refunds.*",
],
})
// expects the payment to be refunded and a new payment session to be created
expect(paymentSessionQuery.data[0].payments[0]).toEqual(
expect.objectContaining({
amount: 3000,
payment_session_id: paymentSession.id,
refunds: [
expect.objectContaining({
note: "Refunded due to cart completion failure",
amount: 3000,
}),
],
captures: [
expect.objectContaining({
amount: 3000,
}),
],
})
)
expect(paymentSessionQuery.data[0].payment_sessions[0].id).not.toBe(
paymentSession.id
)
})
})
})
},
})

View File

@@ -1,11 +1,9 @@
import {
addShippingMethodToCartWorkflow,
addToCartWorkflow,
completeCartWorkflow,
createCartCreditLinesWorkflow,
createCartWorkflow,
createPaymentCollectionForCartWorkflow,
createPaymentSessionsWorkflow,
deleteCartCreditLinesWorkflow,
deleteLineItemsStepId,
deleteLineItemsWorkflow,
@@ -18,6 +16,7 @@ import {
updatePaymentCollectionStepId,
updateTaxLinesWorkflow,
} from "@medusajs/core-flows"
import { StepResponse } from "@medusajs/framework/workflows-sdk"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import {
ICartModuleService,
@@ -36,7 +35,6 @@ import {
Modules,
PriceListStatus,
PriceListType,
remoteQueryObjectFromString,
RuleOperator,
} from "@medusajs/utils"
import {
@@ -47,7 +45,6 @@ import {
} from "../../../../helpers/create-admin-user"
import { seedStorefrontDefaults } from "../../../../helpers/seed-storefront-defaults"
import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer"
import { StepResponse } from "@medusajs/framework/workflows-sdk"
jest.setTimeout(200000)
@@ -1201,326 +1198,6 @@ medusaIntegrationTestRunner({
})
})
describe("CompleteCartWorkflow", () => {
it("should complete cart with custom item", async () => {
const salesChannel = await scModuleService.createSalesChannels({
name: "Webshop",
})
const location = await stockLocationModule.createStockLocations({
name: "Warehouse",
})
const region = await regionModuleService.createRegions({
name: "US",
currency_code: "usd",
})
let cart = await cartModuleService.createCarts({
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
})
await remoteLink.create([
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
},
])
cart = await cartModuleService.retrieveCart(cart.id, {
select: ["id", "region_id", "currency_code", "sales_channel_id"],
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
title: "Test item",
subtitle: "Test subtitle",
thumbnail: "some-url",
requires_shipping: false,
is_discountable: false,
is_tax_inclusive: false,
unit_price: 3000,
metadata: {
foo: "bar",
},
quantity: 1,
},
{
title: "zero price item",
subtitle: "zero price item",
thumbnail: "some-url",
requires_shipping: false,
is_discountable: false,
is_tax_inclusive: false,
unit_price: 0,
quantity: 1,
},
],
cart_id: cart.id,
},
})
cart = await cartModuleService.retrieveCart(cart.id, {
relations: ["items"],
})
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
},
})
const [paymentCollection] =
await paymentModule.listPaymentCollections({})
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: paymentCollection.id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
await completeCartWorkflow(appContainer).run({
input: {
id: cart.id,
},
})
const { data } = await query.graph({
entity: "cart",
filters: {
id: cart.id,
},
fields: ["id", "currency_code", "completed_at", "items.*"],
})
expect(data[0]).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
completed_at: expect.any(Date),
items: [
{
cart_id: cart.id,
compare_at_unit_price: null,
created_at: expect.any(Date),
deleted_at: null,
id: expect.any(String),
is_discountable: false,
is_giftcard: false,
is_tax_inclusive: false,
is_custom_price: true,
metadata: {
foo: "bar",
},
product_collection: null,
product_description: null,
product_handle: null,
product_id: null,
product_subtitle: null,
product_title: null,
product_type: null,
product_type_id: null,
quantity: 1,
raw_compare_at_unit_price: null,
raw_unit_price: {
precision: 20,
value: "3000",
},
requires_shipping: false,
subtitle: "Test subtitle",
thumbnail: "some-url",
title: "Test item",
unit_price: 3000,
updated_at: expect.any(Date),
variant_barcode: null,
variant_id: null,
variant_option_values: null,
variant_sku: null,
variant_title: null,
},
expect.objectContaining({
title: "zero price item",
subtitle: "zero price item",
is_custom_price: true,
unit_price: 0,
}),
],
})
)
})
it("should complete cart reserving inventory from available locations", async () => {
const salesChannel = await scModuleService.createSalesChannels({
name: "Webshop",
})
const location = await stockLocationModule.createStockLocations({
name: "Warehouse",
})
const location2 = await stockLocationModule.createStockLocations({
name: "Side Warehouse",
})
const [product] = await productModule.createProducts([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const inventoryItem = await inventoryModule.createInventoryItems({
sku: "inv-1234",
})
await inventoryModule.createInventoryLevels([
{
inventory_item_id: inventoryItem.id,
location_id: location.id,
stocked_quantity: 1,
reserved_quantity: 0,
},
])
await inventoryModule.createInventoryLevels([
{
inventory_item_id: inventoryItem.id,
location_id: location2.id,
stocked_quantity: 1,
reserved_quantity: 0,
},
])
const priceSet = await pricingModule.createPriceSets({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await pricingModule.createPricePreferences({
attribute: "currency_code",
value: "usd",
is_tax_inclusive: true,
})
await remoteLink.create([
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.PRICING]: {
price_set_id: priceSet.id,
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: location2.id,
},
},
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.INVENTORY]: {
inventory_item_id: inventoryItem.id,
},
},
])
// complete 2 carts
for (let i = 1; i <= 2; i++) {
const cart = await cartModuleService.createCarts({
currency_code: "usd",
sales_channel_id: salesChannel.id,
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
requires_shipping: false,
},
],
cart_id: cart.id,
},
})
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
},
})
const [payCol] = await remoteQuery(
remoteQueryObjectFromString({
entryPoint: "cart_payment_collection",
variables: { filters: { cart_id: cart.id } },
fields: ["payment_collection_id"],
})
)
await createPaymentSessionsWorkflow(appContainer).run({
input: {
payment_collection_id: payCol.payment_collection_id,
provider_id: "pp_system_default",
context: {},
data: {},
},
})
await completeCartWorkflow(appContainer).run({
input: {
id: cart.id,
},
})
}
const reservations = await api.get(
`/admin/reservations`,
adminHeaders
)
const locations = reservations.data.reservations.map(
(r) => r.location_id
)
expect(locations).toEqual(
expect.arrayContaining([location.id, location2.id])
)
})
})
describe("UpdateCartWorkflow", () => {
it("should remove item with custom price when region is updated", async () => {
const hookCallback = jest.fn()