From 0430e63b0bc1b3ed9c13bb6514b22474676e9509 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 6 May 2024 23:34:56 +0200 Subject: [PATCH] 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 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 --- .changeset/large-dots-beg.md | 8 + .../__tests__/cart/store/carts.spec.ts | 196 ++++++++++++++++-- .../src/definition/cart/steps/index.ts | 1 + .../cart/steps/validate-cart-payments.ts | 44 ++++ .../cart/workflows/complete-cart.ts | 27 +-- .../steps/authorize-payment-session.ts | 86 ++++++++ packages/core/types/src/cart/workflows.ts | 6 + packages/core/types/src/payment/service.ts | 6 + packages/core/utils/src/common/errors.ts | 1 + .../api-v2/store/carts/[id]/complete/route.ts | 58 ++++-- .../src/api-v2/store/carts/query-config.ts | 1 - .../src/api-v2/store/carts/validators.ts | 5 + .../src/api-v2/store/orders/query-config.ts | 2 +- .../store/payment-collections/query-config.ts | 5 +- 14 files changed, 391 insertions(+), 55 deletions(-) create mode 100644 .changeset/large-dots-beg.md create mode 100644 packages/core/core-flows/src/definition/cart/steps/validate-cart-payments.ts create mode 100644 packages/core/core-flows/src/payment/steps/authorize-payment-session.ts diff --git a/.changeset/large-dots-beg.md b/.changeset/large-dots-beg.md new file mode 100644 index 0000000000..a016e10d40 --- /dev/null +++ b/.changeset/large-dots-beg.md @@ -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 diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index df9132414a..ecbc6efbbc 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -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", + }, + }) + }) }) }) }, diff --git a/packages/core/core-flows/src/definition/cart/steps/index.ts b/packages/core/core-flows/src/definition/cart/steps/index.ts index 624a9e516c..8efcab9ae5 100644 --- a/packages/core/core-flows/src/definition/cart/steps/index.ts +++ b/packages/core/core-flows/src/definition/cart/steps/index.ts @@ -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" diff --git a/packages/core/core-flows/src/definition/cart/steps/validate-cart-payments.ts b/packages/core/core-flows/src/definition/cart/steps/validate-cart-payments.ts new file mode 100644 index 0000000000..e05342e154 --- /dev/null +++ b/packages/core/core-flows/src/definition/cart/steps/validate-cart-payments.ts @@ -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) + } +) diff --git a/packages/core/core-flows/src/definition/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/definition/cart/workflows/complete-cart.ts index 99ab1c1e3f..94a0330528 100644 --- a/packages/core/core-flows/src/definition/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/definition/cart/workflows/complete-cart.ts @@ -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({ diff --git a/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts b/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts new file mode 100644 index 0000000000..7c9cd16e3d --- /dev/null +++ b/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts @@ -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 +} + +export const authorizePaymentSessionStepId = "authorize-payment-session-step" +export const authorizePaymentSessionStep = createStep( + authorizePaymentSessionStepId, + async (input: StepInput, { container }) => { + let payment: PaymentDTO | undefined + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + const paymentModule = container.resolve( + 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(ContainerRegistrationKeys.LOGGER) + const paymentModule = container.resolve( + 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}` + ) + } + } +) diff --git a/packages/core/types/src/cart/workflows.ts b/packages/core/types/src/cart/workflows.ts index 027a057724..8b075edce2 100644 --- a/packages/core/types/src/cart/workflows.ts +++ b/packages/core/types/src/cart/workflows.ts @@ -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 +} diff --git a/packages/core/types/src/payment/service.ts b/packages/core/types/src/payment/service.ts index 15cce66886..a3413e7bab 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -400,6 +400,12 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise + retrievePaymentSession( + paymentSessionId: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + /* ********** PAYMENT SESSION ********** */ /** diff --git a/packages/core/utils/src/common/errors.ts b/packages/core/utils/src/common/errors.ts index 704667affa..0dc1b3a646 100644 --- a/packages/core/utils/src/common/errors.ts +++ b/packages/core/utils/src/common/errors.ts @@ -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 = { diff --git a/packages/medusa/src/api-v2/store/carts/[id]/complete/route.ts b/packages/medusa/src/api-v2/store/carts/[id]/complete/route.ts index 7492562b19..17e53623a9 100644 --- a/packages/medusa/src/api-v2/store/carts/[id]/complete/route.ts +++ b/packages/medusa/src/api-v2/store/carts/[id]/complete/route.ts @@ -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, @@ -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, + }) } diff --git a/packages/medusa/src/api-v2/store/carts/query-config.ts b/packages/medusa/src/api-v2/store/carts/query-config.ts index 5ae34de5c7..1e65ea0ec0 100644 --- a/packages/medusa/src/api-v2/store/carts/query-config.ts +++ b/packages/medusa/src/api-v2/store/carts/query-config.ts @@ -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", diff --git a/packages/medusa/src/api-v2/store/carts/validators.ts b/packages/medusa/src/api-v2/store/carts/validators.ts index 97bcc7eba5..a1ed20983b 100644 --- a/packages/medusa/src/api-v2/store/carts/validators.ts +++ b/packages/medusa/src/api-v2/store/carts/validators.ts @@ -89,3 +89,8 @@ export const StoreCompleteCart = z }) .strict() export type StoreCompleteCartType = z.infer + +export type StoreCreateCartPaymentCollectionType = z.infer< + typeof StoreCreateCartPaymentCollection +> +export const StoreCreateCartPaymentCollection = z.object({}).strict() diff --git a/packages/medusa/src/api-v2/store/orders/query-config.ts b/packages/medusa/src/api-v2/store/orders/query-config.ts index ee2ddf2b0c..6582720f9c 100644 --- a/packages/medusa/src/api-v2/store/orders/query-config.ts +++ b/packages/medusa/src/api-v2/store/orders/query-config.ts @@ -50,6 +50,6 @@ export const defaultStoreRetrieveOrderFields = [ ] export const retrieveTransformQueryConfig = { - defaultFields: defaultStoreRetrieveOrderFields, + defaults: defaultStoreRetrieveOrderFields, isList: false, } diff --git a/packages/medusa/src/api-v2/store/payment-collections/query-config.ts b/packages/medusa/src/api-v2/store/payment-collections/query-config.ts index d709e3f1fe..cba4b64f90 100644 --- a/packages/medusa/src/api-v2/store/payment-collections/query-config.ts +++ b/packages/medusa/src/api-v2/store/payment-collections/query-config.ts @@ -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 = {