feat(core-flows,typers,utils,medusa): add payment auth step to complete cart workflow - [complete cart part 3] (#7248)

* chore: authorize payment sessions for cart

* chore: add spec for cart returns

* fix: Correctly select fields for cart

* chore: fix specs + address comments

---------

Co-authored-by: Stevche Radevski <sradevski@live.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
Co-authored-by: Carlos R. L. Rodrigues <rodrigolr@gmail.com>
This commit is contained in:
Riqwan Thamir
2024-05-06 23:34:56 +02:00
committed by GitHub
parent 5228b14ca9
commit 0430e63b0b
14 changed files with 391 additions and 55 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@medusajs/core-flows": patch
"@medusajs/types": patch
"@medusajs/utils": patch
"@medusajs/medusa": patch
---
feat(core-flows,typers,utils,medusa): add payment auth step to complete cart workflow
@@ -4,10 +4,12 @@ import {
Modules,
RemoteLink,
} from "@medusajs/modules-sdk"
import PaymentModuleService from "@medusajs/payment/dist/services/payment-module"
import {
ICartModuleService,
ICustomerModuleService,
IFulfillmentModuleService,
IPaymentModuleService,
IPricingModuleService,
IProductModuleService,
IPromotionModuleService,
@@ -17,6 +19,7 @@ import {
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
MedusaError,
PromotionRuleOperator,
PromotionType,
RuleOperator,
@@ -48,6 +51,7 @@ medusaIntegrationTestRunner({
let fulfillmentModule: IFulfillmentModuleService
let remoteLinkService
let regionService: IRegionModuleService
let paymentService: IPaymentModuleService
let defaultRegion
let region
@@ -64,6 +68,7 @@ medusaIntegrationTestRunner({
promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION)
taxModule = appContainer.resolve(ModuleRegistrationName.TAX)
regionService = appContainer.resolve(ModuleRegistrationName.REGION)
paymentService = appContainer.resolve(ModuleRegistrationName.PAYMENT)
fulfillmentModule = appContainer.resolve(
ModuleRegistrationName.FULFILLMENT
)
@@ -1480,11 +1485,14 @@ medusaIntegrationTestRunner({
let shippingProfile
let fulfillmentSet
let shippingOption
let paymentCollection
let paymentSession
let stockLocation
let inventoryItem
beforeEach(async () => {
await setupTaxStructure(taxModule)
region = await regionService.create({
name: "Test region",
countries: ["US"],
@@ -1616,32 +1624,65 @@ medusaIntegrationTestRunner({
adminHeaders
)
).data.shipping_option
paymentCollection = await paymentService.createPaymentCollections({
region_id: region.id,
amount: 1000,
currency_code: "usd",
})
paymentSession = await paymentService.createPaymentSession(
paymentCollection.id,
{
provider_id: "pp_system_default",
amount: 1000,
currency_code: "usd",
data: {},
}
)
})
it("should create an order and create item reservations", async () => {
const cartResponse = await api.post(`/store/carts`, {
currency_code: "usd",
email: "tony@stark-industries.com",
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "ny",
country_code: "us",
province: "ny",
postal_code: "94016",
},
sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }],
})
const cart = (
await api.post(`/store/carts`, {
currency_code: "usd",
email: "tony@stark-industries.com",
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "ny",
country_code: "us",
province: "ny",
postal_code: "94016",
},
sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }],
})
).data.cart
const paymentCollection = (
await api.post(`/store/payment-collections`, {
cart_id: cart.id,
region_id: region.id,
currency_code: region.currency_code,
amount: cart.total,
})
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" }
)
const response = await api.post(
`/store/carts/${cartResponse.data.cart.id}/complete`,
`/store/carts/${cart.id}/complete`,
{}
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
expect(response.data).toEqual({
type: "order",
order: expect.objectContaining({
id: expect.any(String),
total: 106,
subtotal: 100,
@@ -1686,8 +1727,8 @@ medusaIntegrationTestRunner({
province: "ny",
postal_code: "94016",
}),
})
)
}),
})
const reservation = await api.get(`/admin/reservations`, adminHeaders)
const reservationItem = reservation.data.reservations[0]
@@ -1697,11 +1738,124 @@ medusaIntegrationTestRunner({
id: expect.stringContaining("resitem_"),
location_id: stockLocation.id,
inventory_item_id: inventoryItem.id,
quantity: cartResponse.data.cart.items[0].quantity,
line_item_id: cartResponse.data.cart.items[0].id,
quantity: cart.items[0].quantity,
line_item_id: cart.items[0].id,
})
)
})
it("should throw an error when payment collection isn't created", async () => {
const cart = (
await api.post(`/store/carts`, {
currency_code: "usd",
email: "tony@stark-industries.com",
sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }],
})
).data.cart
const error = await api
.post(`/store/carts/${cart.id}/complete`, {})
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
type: "invalid_data",
message: "Payment collection has not been initiated for cart",
})
})
it("should throw an error when payment collection isn't created", async () => {
const cart = (
await api.post(`/store/carts`, {
currency_code: "usd",
email: "tony@stark-industries.com",
sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }],
})
).data.cart
await api.post(`/store/payment-collections`, {
cart_id: cart.id,
region_id: region.id,
currency_code: region.currency_code,
amount: cart.total,
})
const error = await api
.post(`/store/carts/${cart.id}/complete`, {})
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
type: "invalid_data",
message: "Payment sessions are required to complete cart",
})
})
it("should return cart when payment authorization fails", async () => {
const authorizePaymentSessionSpy = jest.spyOn(
PaymentModuleService.prototype,
"authorizePaymentSession"
)
// Mock the authorizePaymentSession to throw error
authorizePaymentSessionSpy.mockImplementation(
(id, context, sharedContext) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Throw a random error`
)
}
)
const cart = (
await api.post(`/store/carts`, {
currency_code: "usd",
email: "tony@stark-industries.com",
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "ny",
country_code: "us",
province: "ny",
postal_code: "94016",
},
sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }],
})
).data.cart
const paymentCollection = (
await api.post(`/store/payment-collections`, {
cart_id: cart.id,
region_id: region.id,
currency_code: region.currency_code,
amount: cart.total,
})
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" }
)
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{}
)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
type: "cart",
cart: expect.objectContaining({}),
error: {
message: "Payment authorization failed",
name: "Error",
type: "payment_authorization_error",
},
})
})
})
})
},
@@ -21,5 +21,6 @@ export * from "./retrieve-cart-with-links"
export * from "./set-tax-lines-for-items"
export * from "./update-cart-promotions"
export * from "./update-carts"
export * from "./validate-cart-payments"
export * from "./validate-cart-shipping-options"
export * from "./validate-variant-prices"
@@ -0,0 +1,44 @@
import { CartWorkflowDTO } from "@medusajs/types"
import { isPresent, MedusaError, PaymentSessionStatus } from "@medusajs/utils"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
interface StepInput {
cart: CartWorkflowDTO
}
export const validateCartPaymentsStepId = "validate-cart-payments"
export const validateCartPaymentsStep = createStep(
validateCartPaymentsStepId,
async (data: StepInput) => {
const {
cart: { payment_collection: paymentCollection },
} = data
if (!isPresent(paymentCollection)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Payment collection has not been initiated for cart`
)
}
// We check if any of these payment sessions are present in the cart
// If not, we throw an error for the consumer to provide a processable payment session
const processablePaymentStatuses = [
PaymentSessionStatus.PENDING,
PaymentSessionStatus.REQUIRES_MORE,
]
const paymentsToProcess = paymentCollection.payment_sessions?.filter((ps) =>
processablePaymentStatuses.includes(ps.status)
)
if (!paymentsToProcess?.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Payment sessions are required to complete cart`
)
}
return new StepResponse(paymentsToProcess)
}
)
@@ -5,23 +5,12 @@ import {
transform,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common"
import { createOrderFromCartStep } from "../steps"
import { authorizePaymentSessionStep } from "../../../payment/steps/authorize-payment-session"
import { createOrderFromCartStep, validateCartPaymentsStep } from "../steps"
import { reserveInventoryStep } from "../steps/reserve-inventory"
import { updateTaxLinesStep } from "../steps/update-tax-lines"
import { completeCartFields } from "../utils/fields"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
/*
- [] Create Tax Lines
- [] Authorize Payment
- fail:
- [] Delete Tax lines
- [] Reserve Item from inventory (if enabled)
- fail:
- [] Delete reservations
- [] Cancel Payment
- [] Create order
*/
export const completeCartWorkflowId = "complete-cart"
export const completeCartWorkflow = createWorkflow(
completeCartWorkflowId,
@@ -33,6 +22,15 @@ export const completeCartWorkflow = createWorkflow(
list: false,
})
const paymentSessions = validateCartPaymentsStep({ cart })
authorizePaymentSessionStep({
// We choose the first payment session, as there will only be one active payment session
// This might change in the future.
id: paymentSessions[0].id,
context: { cart_id: cart.id },
})
const { variants, items, sales_channel_id } = transform(
{ cart },
(data) => {
@@ -44,7 +42,6 @@ export const completeCartWorkflow = createWorkflow(
variant_id: item.variant_id,
quantity: item.quantity,
})
allVariants.push(item.variant)
})
@@ -64,8 +61,6 @@ export const completeCartWorkflow = createWorkflow(
},
})
updateTaxLinesStep({ cart_or_cart_id: cart, force_tax_calculation: true })
reserveInventoryStep(formatedInventoryItems)
const finalCart = useRemoteQueryStep({
@@ -0,0 +1,86 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPaymentModuleService, Logger, PaymentDTO } from "@medusajs/types"
import {
ContainerRegistrationKeys,
MedusaError,
PaymentSessionStatus,
} from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type StepInput = {
id: string
context: Record<string, unknown>
}
export const authorizePaymentSessionStepId = "authorize-payment-session-step"
export const authorizePaymentSessionStep = createStep(
authorizePaymentSessionStepId,
async (input: StepInput, { container }) => {
let payment: PaymentDTO | undefined
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
const paymentModule = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
try {
payment = await paymentModule.authorizePaymentSession(
input.id,
input.context || {}
)
} catch (e) {
logger.error(
`Error was thrown trying to authorize payment session - ${input.id} - ${e}`
)
}
const paymentSession = await paymentModule.retrievePaymentSession(input.id)
// Throw a special error type when the status is requires_more as it requires a specific further action
// from the consumer
if (paymentSession.status === PaymentSessionStatus.REQUIRES_MORE) {
throw new MedusaError(
MedusaError.Types.PAYMENT_REQUIRES_MORE_ERROR,
`More information is required for payment`
)
}
// If any other error other than requires_more shows up, this usually requires the consumer to create a new payment session
// This could also be a system error thats caused by invalid setup or a failure in connecting to external providers
if (paymentSession.status !== PaymentSessionStatus.AUTHORIZED || !payment) {
throw new MedusaError(
MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR,
`Payment authorization failed`
)
}
return new StepResponse(payment)
},
// If payment or any other part of complete cart fails post payment step, we cancel any payments made
async (payment, { container }) => {
if (!payment) {
return
}
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
const paymentModule = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
// If the payment session status is requires_more, we don't have to revert the payment.
// Return the same status for the cart completion to be re-run.
if (
payment.payment_session &&
payment.payment_session.status === PaymentSessionStatus.REQUIRES_MORE
) {
return
}
try {
await paymentModule.cancelPayment(payment.id)
} catch (e) {
logger.error(
`Error was thrown trying to cancel payment - ${payment.id} - ${e}`
)
}
}
)
@@ -1,5 +1,6 @@
import { CustomerDTO } from "../customer"
import { ShippingOptionDTO } from "../fulfillment"
import { PaymentCollectionDTO } from "../payment"
import { ProductDTO } from "../product"
import { RegionDTO } from "../region"
import { BigNumberInput } from "../totals"
@@ -149,3 +150,8 @@ export interface ConfirmVariantInventoryWorkflowInputDTO {
quantity: BigNumberInput
}[]
}
export interface CartWorkflowDTO {
id: string
payment_collection: PaymentCollectionDTO
}
@@ -400,6 +400,12 @@ export interface IPaymentModuleService extends IModuleService {
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
retrievePaymentSession(
paymentSessionId: string,
config?: FindConfig<PaymentSessionDTO>,
sharedContext?: Context
): Promise<PaymentSessionDTO>
/* ********** PAYMENT SESSION ********** */
/**
+1
View File
@@ -14,6 +14,7 @@ export const MedusaErrorTypes = {
UNEXPECTED_STATE: "unexpected_state",
CONFLICT: "conflict",
PAYMENT_AUTHORIZATION_ERROR: "payment_authorization_error",
PAYMENT_REQUIRES_MORE_ERROR: "payment_requires_more_error",
}
export const MedusaErrorCodes = {
@@ -1,7 +1,11 @@
import { completeCartWorkflow } from "@medusajs/core-flows"
import { MedusaError } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { refetchOrder } from "../../../orders/helpers"
import { refetchCart } from "../../helpers"
import { defaultStoreCartFields } from "../../query-config"
import { StoreCompleteCartType } from "../../validators"
import { prepareRetrieveQuery } from "../../../../../utils/get-query-config"
export const POST = async (
req: MedusaRequest<StoreCompleteCartType>,
@@ -9,22 +13,49 @@ export const POST = async (
) => {
const cart_id = req.params.id
// If the idempotencyKey is present:
// - is workflow is running?
// = throw error
// - else
// - re-run the workflow at the failed step
const { errors, result } = await completeCartWorkflow(req.scope).run({
input: { id: req.params.id },
context: {
transactionId: cart_id,
},
input: { id: cart_id },
context: { transactionId: cart_id },
throwOnError: false,
})
// When an error occurs on the workflow, its potentially got to with cart validations, payments
// or inventory checks. Return the cart here along with errors for the consumer to take more action
// and fix them
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
const error = errors[0].error
const statusOKErrors: string[] = [
// TODO: add inventory specific errors
MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR,
MedusaError.Types.PAYMENT_REQUIRES_MORE_ERROR,
]
// If we end up with errors outside of statusOKErrors, it means that the cart is not in a state to be
// completed. In these cases, we return a 400.
const cart = await refetchCart(
cart_id,
req.scope,
prepareRetrieveQuery(
{},
{
defaults: defaultStoreCartFields,
}
).remoteQueryConfig.fields
)
if (!statusOKErrors.includes(error.type)) {
throw error
}
res.status(200).json({
type: "cart",
cart,
error: {
message: error.message,
name: error.name,
type: error.type,
},
})
}
const order = await refetchOrder(
@@ -33,5 +64,8 @@ export const POST = async (
req.remoteQueryConfig.fields
)
res.status(200).json({ order })
res.status(200).json({
type: "order",
order,
})
}
@@ -89,7 +89,6 @@ export const defaultStoreCartFields = [
"*region.countries",
"sales_channel_id",
// TODO: To be updated when payment sessions are introduces in the Rest API
"payment_collection.id",
"payment_collection.amount",
"*payment_collection.payment_sessions",
@@ -89,3 +89,8 @@ export const StoreCompleteCart = z
})
.strict()
export type StoreCompleteCartType = z.infer<typeof StoreCompleteCart>
export type StoreCreateCartPaymentCollectionType = z.infer<
typeof StoreCreateCartPaymentCollection
>
export const StoreCreateCartPaymentCollection = z.object({}).strict()
@@ -50,6 +50,6 @@ export const defaultStoreRetrieveOrderFields = [
]
export const retrieveTransformQueryConfig = {
defaultFields: defaultStoreRetrieveOrderFields,
defaults: defaultStoreRetrieveOrderFields,
isList: false,
}
@@ -2,10 +2,7 @@ export const defaultPaymentCollectionFields = [
"id",
"currency_code",
"amount",
"payment_sessions",
"payment_sessions.id",
"payment_sessions.amount",
"payment_sessions.provider_id",
"*payment_sessions",
]
export const retrievePaymentCollectionTransformQueryConfig = {