diff --git a/.changeset/tricky-rats-guess.md b/.changeset/tricky-rats-guess.md new file mode 100644 index 0000000000..047932ba29 --- /dev/null +++ b/.changeset/tricky-rats-guess.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +fix(core-flows): do not cancel authorized payment on cart complete failure diff --git a/integration-tests/modules/__tests__/cart/store/cart.completion.ts b/integration-tests/modules/__tests__/cart/store/cart.completion.ts new file mode 100644 index 0000000000..3e1dc363ff --- /dev/null +++ b/integration-tests/modules/__tests__/cart/store/cart.completion.ts @@ -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 + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index da05aae507..6640beeba2 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -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() diff --git a/packages/core/core-flows/src/cart/steps/compensate-payment-if-needed.ts b/packages/core/core-flows/src/cart/steps/compensate-payment-if-needed.ts index a6b90fc1e1..b7cf549679 100644 --- a/packages/core/core-flows/src/cart/steps/compensate-payment-if-needed.ts +++ b/packages/core/core-flows/src/cart/steps/compensate-payment-if-needed.ts @@ -1,10 +1,7 @@ -import { IPaymentModuleService, Logger } from "@medusajs/framework/types" -import { - ContainerRegistrationKeys, - Modules, - PaymentSessionStatus, -} from "@medusajs/framework/utils" +import { Logger } from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { refundPaymentAndRecreatePaymentSessionWorkflow } from "../workflows/refund-payment-recreate-payment-session" /** * The payment session's details for compensation. @@ -39,35 +36,45 @@ export const compensatePaymentIfNeededStep = createStep( } const logger = container.resolve(ContainerRegistrationKeys.LOGGER) - const paymentModule = container.resolve( - Modules.PAYMENT - ) + const query = container.resolve(ContainerRegistrationKeys.QUERY) - const paymentSession = await paymentModule.retrievePaymentSession( - paymentSessionId, - { - relations: ["payment"], - } - ) + const { data: paymentSessions } = await query.graph({ + entity: "payment_session", + fields: [ + "id", + "payment_collection_id", + "amount", + "raw_amount", + "provider_id", + "data", + "payment.id", + "payment.captured_at", + "payment.customer.id", + ], + filters: { + id: paymentSessionId, + }, + }) + const paymentSession = paymentSessions[0] - if (paymentSession.status === PaymentSessionStatus.AUTHORIZED) { - try { - await paymentModule.cancelPayment(paymentSession.id) - } catch (e) { - logger.error( - `Error was thrown trying to cancel payment session - ${paymentSession.id} - ${e}` - ) - } + if (!paymentSession) { + return } - if ( - paymentSession.status === PaymentSessionStatus.CAPTURED && - paymentSession.payment?.id - ) { + if (paymentSession.payment?.captured_at) { try { - await paymentModule.refundPayment({ + const workflowInput = { + payment_collection_id: paymentSession.payment_collection_id, + provider_id: paymentSession.provider_id, + customer_id: paymentSession.payment?.customer?.id, + data: paymentSession.data, + amount: paymentSession.raw_amount ?? paymentSession.amount, payment_id: paymentSession.payment.id, note: "Refunded due to cart completion failure", + } + + await refundPaymentAndRecreatePaymentSessionWorkflow(container).run({ + input: workflowInput, }) } catch (e) { logger.error( diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index ef74c77458..595b90cbda 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -112,21 +112,12 @@ export const completeCartWorkflow = createWorkflow( name: "cart-query", }) - // this is only run when the cart is completed for the first time (same condition as below) - // but needs to be before the validation step - const paymentSessions = when( - "create-order-payment-compensation", - { orderId }, - ({ orderId }) => !orderId - ).then(() => { - const paymentSessions = validateCartPaymentsStep({ cart }) - // purpose of this step is to run compensation if cart completion fails - // and tries to cancel or refund the payment depending on the status. - compensatePaymentIfNeededStep({ - payment_session_id: paymentSessions[0].id, - }) - - return paymentSessions + // this needs to be before the validation step + const paymentSessions = validateCartPaymentsStep({ cart }) + // purpose of this step is to run compensation if cart completion fails + // and tries to refund the payment if captured + compensatePaymentIfNeededStep({ + payment_session_id: paymentSessions[0].id, }) const validate = createHook("validate", { diff --git a/packages/core/core-flows/src/cart/workflows/index.ts b/packages/core/core-flows/src/cart/workflows/index.ts index c51f32478c..c35c716236 100644 --- a/packages/core/core-flows/src/cart/workflows/index.ts +++ b/packages/core/core-flows/src/cart/workflows/index.ts @@ -11,6 +11,7 @@ export * from "./list-shipping-options-for-cart-with-pricing" export * from "./refresh-cart-items" export * from "./refresh-cart-shipping-methods" export * from "./refresh-payment-collection" +export * from "./refund-payment-recreate-payment-session" export * from "./transfer-cart-customer" export * from "./update-cart" export * from "./update-cart-promotions" diff --git a/packages/core/core-flows/src/cart/workflows/refund-payment-recreate-payment-session.ts b/packages/core/core-flows/src/cart/workflows/refund-payment-recreate-payment-session.ts new file mode 100644 index 0000000000..472010eb3e --- /dev/null +++ b/packages/core/core-flows/src/cart/workflows/refund-payment-recreate-payment-session.ts @@ -0,0 +1,90 @@ +import { BigNumberInput, PaymentSessionDTO } from "@medusajs/framework/types" +import { + createWorkflow, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { createPaymentSessionsWorkflow } from "../../payment-collection/workflows/create-payment-session" +import { refundPaymentsWorkflow } from "../../payment/workflows/refund-payments" + +/** + * The data to create payment sessions. + */ +export interface refundPaymentAndRecreatePaymentSessionWorkflowInput { + /** + * The ID of the payment collection to create payment sessions for. + */ + payment_collection_id: string + /** + * The ID of the payment provider that the payment sessions are associated with. + * This provider is used to later process the payment sessions and their payments. + */ + provider_id: string + /** + * The ID of the customer that the payment session should be associated with. + */ + customer_id?: string + /** + * Custom data relevant for the payment provider to process the payment session. + * Learn more in [this documentation](https://docs.medusajs.com/resources/commerce-modules/payment/payment-session#data-property). + */ + data?: Record + + /** + * Additional context that's useful for the payment provider to process the payment session. + * Currently all of the context is calculated within the workflow. + */ + context?: Record + + /** + * The ID of the payment to refund. + */ + payment_id: string + + /** + * The amount to refund. + */ + amount: BigNumberInput + + /** + * The note to attach to the refund. + */ + note?: string +} + +export const refundPaymentAndRecreatePaymentSessionWorkflowId = + "refund-payment-and-recreate-payment-session" +/** + * This workflow refunds a payment and creates a new payment session. + * + * @summary + * + * Refund a payment and create a new payment session. + */ +export const refundPaymentAndRecreatePaymentSessionWorkflow = createWorkflow( + refundPaymentAndRecreatePaymentSessionWorkflowId, + ( + input: WorkflowData + ): WorkflowResponse => { + refundPaymentsWorkflow.runAsStep({ + input: [ + { + payment_id: input.payment_id, + note: input.note, + amount: input.amount, + }, + ], + }) + + const paymentSession = createPaymentSessionsWorkflow.runAsStep({ + input: { + payment_collection_id: input.payment_collection_id, + provider_id: input.provider_id, + customer_id: input.customer_id, + data: input.data, + }, + }) + + return new WorkflowResponse(paymentSession) + } +) diff --git a/packages/core/core-flows/src/order/workflows/payments/refund-captured-payments.ts b/packages/core/core-flows/src/order/workflows/payments/refund-captured-payments.ts index d8e4de748c..93b2af5acc 100644 --- a/packages/core/core-flows/src/order/workflows/payments/refund-captured-payments.ts +++ b/packages/core/core-flows/src/order/workflows/payments/refund-captured-payments.ts @@ -7,7 +7,7 @@ import { WorkflowData, } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../../common" -import { refundPaymentsWorkflow } from "../../../payment" +import { refundPaymentsWorkflow } from "../../../payment/workflows/refund-payments" export const refundCapturedPaymentsWorkflowId = "refund-captured-payments-workflow" @@ -20,6 +20,7 @@ export const refundCapturedPaymentsWorkflow = createWorkflow( input: WorkflowData<{ order_id: string created_by?: string + note?: string }> ) => { const orderQuery = useQueryGraphStep({ @@ -74,6 +75,7 @@ export const refundCapturedPaymentsWorkflow = createWorkflow( payment_id: payment.id, created_by: input.created_by, amount: amountToRefund, + note: input.note, } }) .filter((payment) => MathBN.gt(payment.amount, 0)) diff --git a/packages/core/core-flows/src/payment-collection/workflows/index.ts b/packages/core/core-flows/src/payment-collection/workflows/index.ts index f8bbbf5d0d..f7368a7cb3 100644 --- a/packages/core/core-flows/src/payment-collection/workflows/index.ts +++ b/packages/core/core-flows/src/payment-collection/workflows/index.ts @@ -1,5 +1,5 @@ export * from "./create-payment-session" export * from "./create-refund-reasons" export * from "./delete-payment-sessions" +export * from "./delete-refund-reasons" export * from "./update-refund-reasons" -export * from "./delete-refund-reasons" \ No newline at end of file diff --git a/packages/core/core-flows/src/payment/steps/refund-payments.ts b/packages/core/core-flows/src/payment/steps/refund-payments.ts index 78e033213a..6b71f7b558 100644 --- a/packages/core/core-flows/src/payment/steps/refund-payments.ts +++ b/packages/core/core-flows/src/payment/steps/refund-payments.ts @@ -28,6 +28,10 @@ export type RefundPaymentsStepInput = { * The ID of the user that refunded the payment. */ created_by?: string + /** + * The note to attach to the refund. + */ + note?: string }[] export const refundPaymentsStepId = "refund-payments-step" diff --git a/packages/core/core-flows/src/payment/workflows/process-payment.ts b/packages/core/core-flows/src/payment/workflows/process-payment.ts index bf89a29160..c188f726c6 100644 --- a/packages/core/core-flows/src/payment/workflows/process-payment.ts +++ b/packages/core/core-flows/src/payment/workflows/process-payment.ts @@ -71,12 +71,41 @@ export const processPaymentWorkflow = createWorkflow( input.action === PaymentActions.SUCCESSFUL && !!paymentData.data.length ) }).then(() => { - capturePaymentWorkflow.runAsStep({ - input: { - payment_id: paymentData.data[0].id, - amount: input.data?.amount, - }, + capturePaymentWorkflow + .runAsStep({ + input: { + payment_id: paymentData.data[0].id, + amount: input.data?.amount, + }, + }) + .config({ + name: "capture-payment", + }) + }) + + when({ input, paymentData }, ({ input, paymentData }) => { + // payment is captured with the provider but we dont't have any payment data which means we didn't call authorize yet - autocapture flow + return ( + input.action === PaymentActions.SUCCESSFUL && !paymentData.data.length + ) + }).then(() => { + const payment = authorizePaymentSessionStep({ + id: input.data!.session_id, + context: {}, + }).config({ + name: "authorize-payment-session-autocapture", }) + + capturePaymentWorkflow + .runAsStep({ + input: { + payment_id: payment.id, + amount: input.data?.amount, + }, + }) + .config({ + name: "capture-payment-autocapture", + }) }) when( @@ -94,6 +123,8 @@ export const processPaymentWorkflow = createWorkflow( authorizePaymentSessionStep({ id: input.data!.session_id, context: {}, + }).config({ + name: "authorize-payment-session", }) }) diff --git a/packages/core/core-flows/src/payment/workflows/refund-payments.ts b/packages/core/core-flows/src/payment/workflows/refund-payments.ts index e9e73f49cf..55fb6c76d9 100644 --- a/packages/core/core-flows/src/payment/workflows/refund-payments.ts +++ b/packages/core/core-flows/src/payment/workflows/refund-payments.ts @@ -1,5 +1,5 @@ import { BigNumberInput, PaymentDTO } from "@medusajs/framework/types" -import { MathBN, MedusaError } from "@medusajs/framework/utils" +import { isDefined, MathBN, MedusaError } from "@medusajs/framework/utils" import { createStep, createWorkflow, @@ -8,7 +8,7 @@ import { WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../common" -import { addOrderTransactionStep } from "../../order" +import { addOrderTransactionStep } from "../../order/steps/add-order-transaction" import { refundPaymentsStep } from "../steps/refund-payments" /** @@ -29,14 +29,14 @@ export type ValidatePaymentsRefundStepInput = { * This step validates that the refund is valid for the payment. * If the payment's refundable amount is less than the amount to be refunded, * the step throws an error. - * + * * :::note - * + * * You can retrieve a payment's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), * or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep). - * + * * ::: - * + * * @example * const data = validatePaymentsRefundStep({ * payment: [{ @@ -53,10 +53,7 @@ export type ValidatePaymentsRefundStepInput = { */ export const validatePaymentsRefundStep = createStep( "validate-payments-refund-step", - async function ({ - payments, - input, - }: ValidatePaymentsRefundStepInput) { + async function ({ payments, input }: ValidatePaymentsRefundStepInput) { const paymentIdAmountMap = new Map( input.map(({ payment_id, amount }) => [payment_id, amount]) ) @@ -101,15 +98,19 @@ export type RefundPaymentsWorkflowInput = { * The ID of the user that's refunding the payment. */ created_by?: string + /** + * The note to attach to the refund. + */ + note?: string }[] export const refundPaymentsWorkflowId = "refund-payments-workflow" /** * This workflow refunds payments. - * + * * You can use this workflow within your customizations or your own custom workflows, allowing you to * refund payments in your custom flow. - * + * * @example * const { result } = await refundPaymentsWorkflow(container) * .run({ @@ -120,9 +121,9 @@ export const refundPaymentsWorkflowId = "refund-payments-workflow" * } * ] * }) - * + * * @summary - * + * * Refund one or more payments. */ export const refundPaymentsWorkflow = createWorkflow( @@ -171,18 +172,24 @@ export const refundPaymentsWorkflow = createWorkflow( paymentsMap[payment.id] = payment } - return input.map((paymentInput) => { - const payment = paymentsMap[paymentInput.payment_id]! - const order = payment.payment_collection.order + return input + .map((paymentInput) => { + const payment = paymentsMap[paymentInput.payment_id]! + const order = payment.payment_collection?.order - return { - order_id: order.id, - amount: MathBN.mult(paymentInput.amount, -1), - currency_code: payment.currency_code, - reference_id: payment.id, - reference: "refund", - } - }) + if (!order) { + return + } + + return { + order_id: order.id, + amount: MathBN.mult(paymentInput.amount, -1), + currency_code: payment.currency_code, + reference_id: payment.id, + reference: "refund", + } + }) + .filter(isDefined) } )