diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index 111346478b..411db50078 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -1,9 +1,11 @@ import { addToCartWorkflow, createCartWorkflow, + createPaymentCollectionForCartWorkflow, deleteLineItemsStepId, deleteLineItemsWorkflow, findOrCreateCustomerStepId, + linkCartAndPaymentCollectionsStepId, updateLineItemInCartWorkflow, updateLineItemsStepId, } from "@medusajs/core-flows" @@ -11,6 +13,7 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { ICartModuleService, ICustomerModuleService, + IPaymentModuleService, IPricingModuleService, IProductModuleService, IRegionModuleService, @@ -37,7 +40,8 @@ describe("Carts workflows", () => { let customerModule: ICustomerModuleService let productModule: IProductModuleService let pricingModule: IPricingModuleService - let remoteLink + let paymentModule: IPaymentModuleService + let remoteLink, remoteQuery let defaultRegion @@ -52,7 +56,9 @@ describe("Carts workflows", () => { customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER) productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT) pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING) + paymentModule = appContainer.resolve(ModuleRegistrationName.PAYMENT) remoteLink = appContainer.resolve("remoteLink") + remoteQuery = appContainer.resolve("remoteQuery") }) afterAll(async () => { @@ -668,4 +674,142 @@ describe("Carts workflows", () => { }) }) }) + + describe("createPaymentCollectionForCart", () => { + it("should create a payment collection and link it to cart", async () => { + const region = await regionModuleService.create({ + name: "US", + currency_code: "usd", + }) + + const cart = await cartModuleService.create({ + currency_code: "usd", + region_id: region.id, + items: [ + { + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + await createPaymentCollectionForCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, + region_id: region.id, + currency_code: "usd", + amount: 5000, + }, + throwOnError: false, + }) + + const result = await remoteQuery( + { + cart: { + fields: ["id"], + payment_collection: { + fields: ["id", "amount", "currency_code"], + }, + }, + }, + { + cart: { + id: cart.id, + }, + } + ) + + expect(result).toEqual([ + expect.objectContaining({ + id: cart.id, + payment_collection: expect.objectContaining({ + amount: 5000, + currency_code: "usd", + }), + }), + ]) + }) + + describe("compensation", () => { + it("should dismiss cart <> payment collection link and delete created payment collection", async () => { + const workflow = createPaymentCollectionForCartWorkflow(appContainer) + + workflow.appendAction("throw", linkCartAndPaymentCollectionsStepId, { + invoke: async function failStep() { + throw new Error( + `Failed to do something after linking cart and payment collection` + ) + }, + }) + + const region = await regionModuleService.create({ + name: "US", + currency_code: "usd", + }) + + const cart = await cartModuleService.create({ + currency_code: "usd", + region_id: region.id, + items: [ + { + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + const { errors } = await workflow.run({ + input: { + cart_id: cart.id, + region_id: region.id, + currency_code: "usd", + amount: 5000, + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: new Error( + `Failed to do something after linking cart and payment collection` + ), + }, + ]) + + const carts = await remoteQuery( + { + cart: { + fields: ["id"], + payment_collection: { + fields: ["id", "amount", "currency_code"], + }, + }, + }, + { + cart: { + id: cart.id, + }, + } + ) + + const payCols = await remoteQuery({ + payment_collection: { + fields: ["id"], + }, + }) + + expect(carts).toEqual([ + expect.objectContaining({ + id: cart.id, + payment_collection: undefined, + }), + ]) + expect(payCols.length).toEqual(0) + }) + }) + }) }) diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 4249de1705..f77d89341e 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -669,4 +669,35 @@ describe("Store Carts API", () => { ) }) }) + + describe("POST /store/carts/:id/payment-collections", () => { + it("should create a payment collection for the cart", async () => { + const region = await regionModuleService.create({ + name: "US", + currency_code: "usd", + }) + + const cart = await cartModuleService.create({ + currency_code: "usd", + region_id: region.id, + }) + + const api = useApi() as any + const response = await api.post( + `/store/carts/${cart.id}/payment-collections` + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + payment_collection: expect.objectContaining({ + id: expect.any(String), + amount: 0, + }), + }) + ) + }) + }) }) diff --git a/packages/core-flows/src/definition/cart/steps/create-payment-collection.ts b/packages/core-flows/src/definition/cart/steps/create-payment-collection.ts new file mode 100644 index 0000000000..dd26222ddc --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/create-payment-collection.ts @@ -0,0 +1,38 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPaymentModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type StepInput = { + region_id: string + currency_code: string + amount: number + metadata?: Record +} + +export const createPaymentCollectionsStepId = "create-payment-collections" +export const createPaymentCollectionsStep = createStep( + createPaymentCollectionsStepId, + async (data: StepInput[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + const created = await service.createPaymentCollections(data) + + return new StepResponse( + created, + created.map((collection) => collection.id) + ) + }, + async (createdIds, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + await service.deletePaymentCollections(createdIds) + } +) diff --git a/packages/core-flows/src/definition/cart/steps/link-cart-payment-collection.ts b/packages/core-flows/src/definition/cart/steps/link-cart-payment-collection.ts new file mode 100644 index 0000000000..5484efcd4e --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/link-cart-payment-collection.ts @@ -0,0 +1,41 @@ +import { Modules } from "@medusajs/modules-sdk" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type StepInput = { + links: { + cart_id: string + payment_collection_id: string + }[] +} + +export const linkCartAndPaymentCollectionsStepId = + "link-cart-payment-collection" +export const linkCartAndPaymentCollectionsStep = createStep( + linkCartAndPaymentCollectionsStepId, + async (data: StepInput, { container }) => { + const remoteLink = container.resolve("remoteLink") + + const links = data.links.map((d) => ({ + [Modules.CART]: { cart_id: d.cart_id }, + [Modules.PAYMENT]: { payment_collection_id: d.payment_collection_id }, + })) + + await remoteLink.create(links) + + return new StepResponse(void 0, data) + }, + async (data, { container }) => { + if (!data) { + return + } + + const remoteLink = container.resolve("remoteLink") + + const links = data.links.map((d) => ({ + [Modules.CART]: { cart_id: d.cart_id }, + [Modules.PAYMENT]: { payment_collection_id: d.payment_collection_id }, + })) + + await remoteLink.dismiss(links) + } +) diff --git a/packages/core-flows/src/definition/cart/steps/retrieve-cart.ts b/packages/core-flows/src/definition/cart/steps/retrieve-cart.ts index 9ce4f9b368..d266f07a13 100644 --- a/packages/core-flows/src/definition/cart/steps/retrieve-cart.ts +++ b/packages/core-flows/src/definition/cart/steps/retrieve-cart.ts @@ -4,7 +4,7 @@ import { StepResponse, createStep } from "@medusajs/workflows-sdk" interface StepInput { id: string - config: FindConfig + config?: FindConfig } export const retrieveCartStepId = "retrieve-cart" diff --git a/packages/core-flows/src/definition/cart/workflows/create-payment-collection-for-cart.ts b/packages/core-flows/src/definition/cart/workflows/create-payment-collection-for-cart.ts new file mode 100644 index 0000000000..de6fac87b5 --- /dev/null +++ b/packages/core-flows/src/definition/cart/workflows/create-payment-collection-for-cart.ts @@ -0,0 +1,38 @@ +import { + CartDTO, + CreatePaymentCollectionForCartWorkflowInputDTO, +} from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { retrieveCartStep } from "../steps" +import { createPaymentCollectionsStep } from "../steps/create-payment-collection" +import { linkCartAndPaymentCollectionsStep } from "../steps/link-cart-payment-collection" + +export const createPaymentCollectionForCartWorkflowId = + "create-payment-collection-for-cart" +export const createPaymentCollectionForCartWorkflow = createWorkflow( + createPaymentCollectionForCartWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + const created = createPaymentCollectionsStep([input]) + + const link = transform({ cartId: input.cart_id, created }, (data) => ({ + links: [ + { + cart_id: data.cartId, + payment_collection_id: data.created[0].id, + }, + ], + })) + + linkCartAndPaymentCollectionsStep(link) + + const cart = retrieveCartStep({ id: input.cart_id }) + + return cart + } +) diff --git a/packages/core-flows/src/definition/cart/workflows/index.ts b/packages/core-flows/src/definition/cart/workflows/index.ts index 5092090483..12c91c1f01 100644 --- a/packages/core-flows/src/definition/cart/workflows/index.ts +++ b/packages/core-flows/src/definition/cart/workflows/index.ts @@ -1,6 +1,6 @@ export * from "./add-to-cart" export * from "./create-carts" +export * from "./create-payment-collection-for-cart" export * from "./update-cart" export * from "./update-cart-promotions" export * from "./update-line-item-in-cart" - diff --git a/packages/medusa/src/api-v2/store/carts/[id]/payment-collections/route.ts b/packages/medusa/src/api-v2/store/carts/[id]/payment-collections/route.ts new file mode 100644 index 0000000000..9139444588 --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/payment-collections/route.ts @@ -0,0 +1,50 @@ +import { createPaymentCollectionForCartWorkflow } from "@medusajs/core-flows" +import { UpdateCartDataDTO } from "@medusajs/types" +import { remoteQueryObjectFromString } from "@medusajs/utils" + +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" +import { defaultStoreCartFields } from "../../query-config" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const workflow = createPaymentCollectionForCartWorkflow(req.scope) + + const remoteQuery = req.scope.resolve("remoteQuery") + + let [cart] = await remoteQuery( + { + cart: { + fields: ["id", "currency_code", "region_id", "total"], + }, + }, + { + cart: { id: req.params.id }, + } + ) + + const { errors } = await workflow.run({ + input: { + cart_id: req.params.id, + region_id: cart.region_id, + currency_code: cart.currency_code, + amount: cart.total ?? 0, // TODO: This should be calculated from the cart when totals decoration is introduced + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const query = remoteQueryObjectFromString({ + entryPoint: "cart", + variables: { id: req.params.id }, + fields: defaultStoreCartFields, + }) + + ;[cart] = await remoteQuery(query) + + res.status(200).json({ cart }) +} 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 3d5fb30aa6..2220f8a497 100644 --- a/packages/medusa/src/api-v2/store/carts/query-config.ts +++ b/packages/medusa/src/api-v2/store/carts/query-config.ts @@ -42,6 +42,11 @@ export const defaultStoreCartFields = [ "region.name", "region.currency_code", "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", ] export const defaultStoreCartRelations = [ diff --git a/packages/payment/src/joiner-config.ts b/packages/payment/src/joiner-config.ts index 4bffc28e73..b77f408531 100644 --- a/packages/payment/src/joiner-config.ts +++ b/packages/payment/src/joiner-config.ts @@ -23,10 +23,18 @@ export const joinerConfig: ModuleJoinerConfig = { serviceName: Modules.PAYMENT, primaryKeys: ["id"], linkableKeys: LinkableKeys, - alias: { - name: ["payment", "payments"], - args: { - entity: Payment.name, + alias: [ + { + name: ["payment", "payments"], + args: { + entity: Payment.name, + }, }, - }, + { + name: ["payment_collection", "payment_collections"], + args: { + entity: PaymentCollection.name, + }, + }, + ], } diff --git a/packages/types/src/cart/workflows.ts b/packages/types/src/cart/workflows.ts index fdaf82ac7f..226fedd020 100644 --- a/packages/types/src/cart/workflows.ts +++ b/packages/types/src/cart/workflows.ts @@ -83,3 +83,11 @@ export interface UpdateCartWorkflowInputDTO { currency_code?: string metadata?: Record | null } + +export interface CreatePaymentCollectionForCartWorkflowInputDTO { + cart_id: string + region_id: string + currency_code: string + amount: number + metadata?: Record +}