fix(core-flows): Refund and recreate payment session on cart complete failure (#12263)
This commit is contained in:
1 parent
3fb4d5beb0
commit
5fe0e8250d
12 files changed
+967
-399
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/core-flows": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(core-flows): do not cancel authorized payment on cart complete failure
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
addShippingMethodToCartWorkflow,
|
addShippingMethodToCartWorkflow,
|
||||||
addToCartWorkflow,
|
addToCartWorkflow,
|
||||||
completeCartWorkflow,
|
|
||||||
createCartCreditLinesWorkflow,
|
createCartCreditLinesWorkflow,
|
||||||
createCartWorkflow,
|
createCartWorkflow,
|
||||||
createPaymentCollectionForCartWorkflow,
|
createPaymentCollectionForCartWorkflow,
|
||||||
createPaymentSessionsWorkflow,
|
|
||||||
deleteCartCreditLinesWorkflow,
|
deleteCartCreditLinesWorkflow,
|
||||||
deleteLineItemsStepId,
|
deleteLineItemsStepId,
|
||||||
deleteLineItemsWorkflow,
|
deleteLineItemsWorkflow,
|
||||||
@@ -18,6 +16,7 @@ import {
|
|||||||
updatePaymentCollectionStepId,
|
updatePaymentCollectionStepId,
|
||||||
updateTaxLinesWorkflow,
|
updateTaxLinesWorkflow,
|
||||||
} from "@medusajs/core-flows"
|
} from "@medusajs/core-flows"
|
||||||
|
import { StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
|
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
|
||||||
import {
|
import {
|
||||||
ICartModuleService,
|
ICartModuleService,
|
||||||
@@ -36,7 +35,6 @@ import {
|
|||||||
Modules,
|
Modules,
|
||||||
PriceListStatus,
|
PriceListStatus,
|
||||||
PriceListType,
|
PriceListType,
|
||||||
remoteQueryObjectFromString,
|
|
||||||
RuleOperator,
|
RuleOperator,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +45,6 @@ import {
|
|||||||
} from "../../../../helpers/create-admin-user"
|
} from "../../../../helpers/create-admin-user"
|
||||||
import { seedStorefrontDefaults } from "../../../../helpers/seed-storefront-defaults"
|
import { seedStorefrontDefaults } from "../../../../helpers/seed-storefront-defaults"
|
||||||
import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer"
|
import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer"
|
||||||
import { StepResponse } from "@medusajs/framework/workflows-sdk"
|
|
||||||
|
|
||||||
jest.setTimeout(200000)
|
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", () => {
|
describe("UpdateCartWorkflow", () => {
|
||||||
it("should remove item with custom price when region is updated", async () => {
|
it("should remove item with custom price when region is updated", async () => {
|
||||||
const hookCallback = jest.fn()
|
const hookCallback = jest.fn()
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { IPaymentModuleService, Logger } from "@medusajs/framework/types"
|
import { Logger } from "@medusajs/framework/types"
|
||||||
import {
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
ContainerRegistrationKeys,
|
|
||||||
Modules,
|
|
||||||
PaymentSessionStatus,
|
|
||||||
} from "@medusajs/framework/utils"
|
|
||||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { refundPaymentAndRecreatePaymentSessionWorkflow } from "../workflows/refund-payment-recreate-payment-session"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The payment session's details for compensation.
|
* The payment session's details for compensation.
|
||||||
@@ -39,35 +36,45 @@ export const compensatePaymentIfNeededStep = createStep(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
|
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
|
||||||
const paymentModule = container.resolve<IPaymentModuleService>(
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
Modules.PAYMENT
|
|
||||||
)
|
|
||||||
|
|
||||||
const paymentSession = await paymentModule.retrievePaymentSession(
|
const { data: paymentSessions } = await query.graph({
|
||||||
paymentSessionId,
|
entity: "payment_session",
|
||||||
{
|
fields: [
|
||||||
relations: ["payment"],
|
"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) {
|
if (!paymentSession) {
|
||||||
try {
|
return
|
||||||
await paymentModule.cancelPayment(paymentSession.id)
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(
|
|
||||||
`Error was thrown trying to cancel payment session - ${paymentSession.id} - ${e}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (paymentSession.payment?.captured_at) {
|
||||||
paymentSession.status === PaymentSessionStatus.CAPTURED &&
|
|
||||||
paymentSession.payment?.id
|
|
||||||
) {
|
|
||||||
try {
|
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,
|
payment_id: paymentSession.payment.id,
|
||||||
note: "Refunded due to cart completion failure",
|
note: "Refunded due to cart completion failure",
|
||||||
|
}
|
||||||
|
|
||||||
|
await refundPaymentAndRecreatePaymentSessionWorkflow(container).run({
|
||||||
|
input: workflowInput,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -112,21 +112,12 @@ export const completeCartWorkflow = createWorkflow(
|
|||||||
name: "cart-query",
|
name: "cart-query",
|
||||||
})
|
})
|
||||||
|
|
||||||
// this is only run when the cart is completed for the first time (same condition as below)
|
// this needs to be before the validation step
|
||||||
// but needs to be before the validation step
|
const paymentSessions = validateCartPaymentsStep({ cart })
|
||||||
const paymentSessions = when(
|
// purpose of this step is to run compensation if cart completion fails
|
||||||
"create-order-payment-compensation",
|
// and tries to refund the payment if captured
|
||||||
{ orderId },
|
compensatePaymentIfNeededStep({
|
||||||
({ orderId }) => !orderId
|
payment_session_id: paymentSessions[0].id,
|
||||||
).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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const validate = createHook("validate", {
|
const validate = createHook("validate", {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export * from "./list-shipping-options-for-cart-with-pricing"
|
|||||||
export * from "./refresh-cart-items"
|
export * from "./refresh-cart-items"
|
||||||
export * from "./refresh-cart-shipping-methods"
|
export * from "./refresh-cart-shipping-methods"
|
||||||
export * from "./refresh-payment-collection"
|
export * from "./refresh-payment-collection"
|
||||||
|
export * from "./refund-payment-recreate-payment-session"
|
||||||
export * from "./transfer-cart-customer"
|
export * from "./transfer-cart-customer"
|
||||||
export * from "./update-cart"
|
export * from "./update-cart"
|
||||||
export * from "./update-cart-promotions"
|
export * from "./update-cart-promotions"
|
||||||
|
|||||||
+90
@@ -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<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<refundPaymentAndRecreatePaymentSessionWorkflowInput>
|
||||||
|
): WorkflowResponse<PaymentSessionDTO> => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
WorkflowData,
|
WorkflowData,
|
||||||
} from "@medusajs/framework/workflows-sdk"
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
import { useQueryGraphStep } from "../../../common"
|
import { useQueryGraphStep } from "../../../common"
|
||||||
import { refundPaymentsWorkflow } from "../../../payment"
|
import { refundPaymentsWorkflow } from "../../../payment/workflows/refund-payments"
|
||||||
|
|
||||||
export const refundCapturedPaymentsWorkflowId =
|
export const refundCapturedPaymentsWorkflowId =
|
||||||
"refund-captured-payments-workflow"
|
"refund-captured-payments-workflow"
|
||||||
@@ -20,6 +20,7 @@ export const refundCapturedPaymentsWorkflow = createWorkflow(
|
|||||||
input: WorkflowData<{
|
input: WorkflowData<{
|
||||||
order_id: string
|
order_id: string
|
||||||
created_by?: string
|
created_by?: string
|
||||||
|
note?: string
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const orderQuery = useQueryGraphStep({
|
const orderQuery = useQueryGraphStep({
|
||||||
@@ -74,6 +75,7 @@ export const refundCapturedPaymentsWorkflow = createWorkflow(
|
|||||||
payment_id: payment.id,
|
payment_id: payment.id,
|
||||||
created_by: input.created_by,
|
created_by: input.created_by,
|
||||||
amount: amountToRefund,
|
amount: amountToRefund,
|
||||||
|
note: input.note,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((payment) => MathBN.gt(payment.amount, 0))
|
.filter((payment) => MathBN.gt(payment.amount, 0))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export * from "./create-payment-session"
|
export * from "./create-payment-session"
|
||||||
export * from "./create-refund-reasons"
|
export * from "./create-refund-reasons"
|
||||||
export * from "./delete-payment-sessions"
|
export * from "./delete-payment-sessions"
|
||||||
|
export * from "./delete-refund-reasons"
|
||||||
export * from "./update-refund-reasons"
|
export * from "./update-refund-reasons"
|
||||||
export * from "./delete-refund-reasons"
|
|
||||||
@@ -28,6 +28,10 @@ export type RefundPaymentsStepInput = {
|
|||||||
* The ID of the user that refunded the payment.
|
* The ID of the user that refunded the payment.
|
||||||
*/
|
*/
|
||||||
created_by?: string
|
created_by?: string
|
||||||
|
/**
|
||||||
|
* The note to attach to the refund.
|
||||||
|
*/
|
||||||
|
note?: string
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
export const refundPaymentsStepId = "refund-payments-step"
|
export const refundPaymentsStepId = "refund-payments-step"
|
||||||
|
|||||||
@@ -71,12 +71,41 @@ export const processPaymentWorkflow = createWorkflow(
|
|||||||
input.action === PaymentActions.SUCCESSFUL && !!paymentData.data.length
|
input.action === PaymentActions.SUCCESSFUL && !!paymentData.data.length
|
||||||
)
|
)
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
capturePaymentWorkflow.runAsStep({
|
capturePaymentWorkflow
|
||||||
input: {
|
.runAsStep({
|
||||||
payment_id: paymentData.data[0].id,
|
input: {
|
||||||
amount: input.data?.amount,
|
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(
|
when(
|
||||||
@@ -94,6 +123,8 @@ export const processPaymentWorkflow = createWorkflow(
|
|||||||
authorizePaymentSessionStep({
|
authorizePaymentSessionStep({
|
||||||
id: input.data!.session_id,
|
id: input.data!.session_id,
|
||||||
context: {},
|
context: {},
|
||||||
|
}).config({
|
||||||
|
name: "authorize-payment-session",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BigNumberInput, PaymentDTO } from "@medusajs/framework/types"
|
import { BigNumberInput, PaymentDTO } from "@medusajs/framework/types"
|
||||||
import { MathBN, MedusaError } from "@medusajs/framework/utils"
|
import { isDefined, MathBN, MedusaError } from "@medusajs/framework/utils"
|
||||||
import {
|
import {
|
||||||
createStep,
|
createStep,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
WorkflowResponse,
|
WorkflowResponse,
|
||||||
} from "@medusajs/framework/workflows-sdk"
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
import { useQueryGraphStep } from "../../common"
|
import { useQueryGraphStep } from "../../common"
|
||||||
import { addOrderTransactionStep } from "../../order"
|
import { addOrderTransactionStep } from "../../order/steps/add-order-transaction"
|
||||||
import { refundPaymentsStep } from "../steps/refund-payments"
|
import { refundPaymentsStep } from "../steps/refund-payments"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,14 +29,14 @@ export type ValidatePaymentsRefundStepInput = {
|
|||||||
* This step validates that the refund is valid for the payment.
|
* 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,
|
* If the payment's refundable amount is less than the amount to be refunded,
|
||||||
* the step throws an error.
|
* the step throws an error.
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can retrieve a payment's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query),
|
* 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).
|
* or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep).
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const data = validatePaymentsRefundStep({
|
* const data = validatePaymentsRefundStep({
|
||||||
* payment: [{
|
* payment: [{
|
||||||
@@ -53,10 +53,7 @@ export type ValidatePaymentsRefundStepInput = {
|
|||||||
*/
|
*/
|
||||||
export const validatePaymentsRefundStep = createStep(
|
export const validatePaymentsRefundStep = createStep(
|
||||||
"validate-payments-refund-step",
|
"validate-payments-refund-step",
|
||||||
async function ({
|
async function ({ payments, input }: ValidatePaymentsRefundStepInput) {
|
||||||
payments,
|
|
||||||
input,
|
|
||||||
}: ValidatePaymentsRefundStepInput) {
|
|
||||||
const paymentIdAmountMap = new Map<string, BigNumberInput>(
|
const paymentIdAmountMap = new Map<string, BigNumberInput>(
|
||||||
input.map(({ payment_id, amount }) => [payment_id, amount])
|
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.
|
* The ID of the user that's refunding the payment.
|
||||||
*/
|
*/
|
||||||
created_by?: string
|
created_by?: string
|
||||||
|
/**
|
||||||
|
* The note to attach to the refund.
|
||||||
|
*/
|
||||||
|
note?: string
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
export const refundPaymentsWorkflowId = "refund-payments-workflow"
|
export const refundPaymentsWorkflowId = "refund-payments-workflow"
|
||||||
/**
|
/**
|
||||||
* This workflow refunds payments.
|
* This workflow refunds payments.
|
||||||
*
|
*
|
||||||
* You can use this workflow within your customizations or your own custom workflows, allowing you to
|
* You can use this workflow within your customizations or your own custom workflows, allowing you to
|
||||||
* refund payments in your custom flow.
|
* refund payments in your custom flow.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const { result } = await refundPaymentsWorkflow(container)
|
* const { result } = await refundPaymentsWorkflow(container)
|
||||||
* .run({
|
* .run({
|
||||||
@@ -120,9 +121,9 @@ export const refundPaymentsWorkflowId = "refund-payments-workflow"
|
|||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* @summary
|
* @summary
|
||||||
*
|
*
|
||||||
* Refund one or more payments.
|
* Refund one or more payments.
|
||||||
*/
|
*/
|
||||||
export const refundPaymentsWorkflow = createWorkflow(
|
export const refundPaymentsWorkflow = createWorkflow(
|
||||||
@@ -171,18 +172,24 @@ export const refundPaymentsWorkflow = createWorkflow(
|
|||||||
paymentsMap[payment.id] = payment
|
paymentsMap[payment.id] = payment
|
||||||
}
|
}
|
||||||
|
|
||||||
return input.map((paymentInput) => {
|
return input
|
||||||
const payment = paymentsMap[paymentInput.payment_id]!
|
.map((paymentInput) => {
|
||||||
const order = payment.payment_collection.order
|
const payment = paymentsMap[paymentInput.payment_id]!
|
||||||
|
const order = payment.payment_collection?.order
|
||||||
|
|
||||||
return {
|
if (!order) {
|
||||||
order_id: order.id,
|
return
|
||||||
amount: MathBN.mult(paymentInput.amount, -1),
|
}
|
||||||
currency_code: payment.currency_code,
|
|
||||||
reference_id: payment.id,
|
return {
|
||||||
reference: "refund",
|
order_id: order.id,
|
||||||
}
|
amount: MathBN.mult(paymentInput.amount, -1),
|
||||||
})
|
currency_code: payment.currency_code,
|
||||||
|
reference_id: payment.id,
|
||||||
|
reference: "refund",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(isDefined)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user