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 f0464755a0..ff3b0c3c47 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -6,10 +6,12 @@ import { deleteLineItemsWorkflow, findOrCreateCustomerStepId, linkCartAndPaymentCollectionsStepId, + refreshPaymentCollectionForCartWorkflow, updateLineItemInCartWorkflow, updateLineItemsStepId, + updatePaymentCollectionStepId, } from "@medusajs/core-flows" -import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk" import { ICartModuleService, ICustomerModuleService, @@ -19,8 +21,8 @@ import { IRegionModuleService, ISalesChannelModuleService, } from "@medusajs/types" -import adminSeeder from "../../../../helpers/admin-seeder" import { medusaIntegrationTestRunner } from "medusa-test-utils" +import adminSeeder from "../../../../helpers/admin-seeder" jest.setTimeout(200000) @@ -664,89 +666,12 @@ medusaIntegrationTestRunner({ expect(updatedItem).not.toBeUndefined() }) }) - }) - - 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", - }) + describe("createPaymentCollectionForCart", () => { + it("should create a payment collection and link it to cart", async () => { const cart = await cartModuleService.create({ - currency_code: "usd", - region_id: region.id, + currency_code: "dkk", + region_id: defaultRegion.id, items: [ { quantity: 1, @@ -756,27 +681,17 @@ medusaIntegrationTestRunner({ ], }) - const { errors } = await workflow.run({ + await createPaymentCollectionForCartWorkflow(appContainer).run({ input: { cart_id: cart.id, - region_id: region.id, - currency_code: "usd", + region_id: defaultRegion.id, + currency_code: "dkk", 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( + const result = await remoteQuery( { cart: { fields: ["id"], @@ -792,19 +707,274 @@ medusaIntegrationTestRunner({ } ) - const payCols = await remoteQuery({ - payment_collection: { - fields: ["id"], + expect(result).toEqual([ + expect.objectContaining({ + id: cart.id, + payment_collection: expect.objectContaining({ + amount: 5000, + currency_code: "dkk", + }), + }), + ]) + }) + + 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) + }) + }) + }) + }) + + describe("refreshPaymentCollectionForCart", () => { + it("should refresh a payment collection for a cart", async () => { + const cart = await cartModuleService.create({ + currency_code: "dkk", + region_id: defaultRegion.id, + items: [ + { + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + const paymentCollection = + await paymentModule.createPaymentCollections({ + amount: 5000, + currency_code: "dkk", + region_id: defaultRegion.id, + }) + + const paymentSession = await paymentModule.createPaymentSession( + paymentCollection.id, + { + amount: 5000, + currency_code: "dkk", + data: {}, + provider_id: "pp_system_default", + } + ) + + await remoteLink.create([ + { + [Modules.CART]: { + cart_id: cart.id, + }, + [Modules.PAYMENT]: { + payment_collection_id: paymentCollection.id, + }, + }, + ]) + + await refreshPaymentCollectionForCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, + }, + throwOnError: false, + }) + + const updatedPaymentCollection = + await paymentModule.retrievePaymentCollection(paymentCollection.id) + + expect(updatedPaymentCollection).toEqual( + expect.objectContaining({ + id: paymentCollection.id, + amount: 4242, + }) + ) + + const sessionShouldNotExist = await paymentModule.listPaymentSessions( + { id: paymentSession.id }, + { withDeleted: true } + ) + + expect(sessionShouldNotExist).toHaveLength(0) + }) + + describe("compensation", () => { + it("should revert payment collection amount and create a new payment session", async () => { + const region = await regionModuleService.create({ + name: "US", + currency_code: "usd", + }) + + const testCart = await cartModuleService.create({ + currency_code: "usd", + region_id: region.id, + items: [ + { + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + const paymentCollection = + await paymentModule.createPaymentCollections({ + amount: 5000, + currency_code: "dkk", + region_id: defaultRegion.id, + }) + + const paymentSession = await paymentModule.createPaymentSession( + paymentCollection.id, + { + amount: 5000, + currency_code: "dkk", + data: {}, + provider_id: "pp_system_default", + } + ) + + await remoteLink.create([ + { + [Modules.CART]: { + cart_id: testCart.id, + }, + [Modules.PAYMENT]: { + payment_collection_id: paymentCollection.id, + }, + }, + ]) + + const workflow = + refreshPaymentCollectionForCartWorkflow(appContainer) + + workflow.appendAction("throw", updatePaymentCollectionStepId, { + invoke: async function failStep() { + throw new Error( + `Failed to do something after updating payment collections` + ) }, }) - expect(carts).toEqual([ - expect.objectContaining({ - id: cart.id, - payment_collection: undefined, - }), + const { errors } = await workflow.run({ + input: { + cart_id: testCart.id, + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: new Error( + `Failed to do something after updating payment collections` + ), + }, ]) - expect(payCols.length).toEqual(0) + + const updatedPaymentCollection = + await paymentModule.retrievePaymentCollection( + paymentCollection.id, + { + relations: ["payment_sessions"], + } + ) + + const sessions = await paymentModule.listPaymentSessions({ + payment_collection_id: paymentCollection.id, + }) + + expect(sessions).toHaveLength(1) + expect(sessions[0].id).not.toEqual(paymentSession.id) + expect(sessions[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + amount: 5000, + currency_code: "dkk", + }) + ) + expect(updatedPaymentCollection).toEqual( + expect.objectContaining({ + id: paymentCollection.id, + amount: 5000, + }) + ) }) }) }) diff --git a/integration-tests/modules/package.json b/integration-tests/modules/package.json index 7d207539fb..71f9a588f8 100644 --- a/integration-tests/modules/package.json +++ b/integration-tests/modules/package.json @@ -5,7 +5,7 @@ "license": "MIT", "private": true, "scripts": { - "test:integration": "node --expose-gc ./../../node_modules/.bin/jest --ci --silent=true -i --detectOpenHandles --logHeapUsage --forceExit", + "test:integration": "node --expose-gc ./../../node_modules/.bin/jest --ci --silent=false -i --detectOpenHandles --logHeapUsage --forceExit", "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { diff --git a/packages/core-flows/src/common/steps/use-remote-query.ts b/packages/core-flows/src/common/steps/use-remote-query.ts new file mode 100644 index 0000000000..ad4ab203b4 --- /dev/null +++ b/packages/core-flows/src/common/steps/use-remote-query.ts @@ -0,0 +1,26 @@ +import { remoteQueryObjectFromString } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + entry_point: string + fields: string[] + variables?: Record +} + +export const useRemoteQueryStepId = "use-remote-query" +export const useRemoteQueryStep = createStep( + useRemoteQueryStepId, + async (data: StepInput, { container }) => { + const query = container.resolve("remoteQuery") + + const queryObject = remoteQueryObjectFromString({ + entryPoint: data.entry_point, + fields: data.fields, + variables: data.variables, + }) + + const result = await query(queryObject) + + return new StepResponse(result) + } +) diff --git a/packages/core-flows/src/definition/cart/workflows/index.ts b/packages/core-flows/src/definition/cart/workflows/index.ts index 12c91c1f01..fbf89e5539 100644 --- a/packages/core-flows/src/definition/cart/workflows/index.ts +++ b/packages/core-flows/src/definition/cart/workflows/index.ts @@ -1,6 +1,7 @@ export * from "./add-to-cart" export * from "./create-carts" export * from "./create-payment-collection-for-cart" +export * from "./refresh-payment-collection" export * from "./update-cart" export * from "./update-cart-promotions" export * from "./update-line-item-in-cart" diff --git a/packages/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts b/packages/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts new file mode 100644 index 0000000000..1f020a67ba --- /dev/null +++ b/packages/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts @@ -0,0 +1,70 @@ +import { + StepResponse, + WorkflowData, + createStep, + createWorkflow, +} from "@medusajs/workflows-sdk" +import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" +import { + deletePaymentSessionStep, + updatePaymentCollectionStep, +} from "../../payment-collection" + +type WorklowInput = { + cart_id: string +} + +interface StepInput { + cart_id: string +} + +// We export a step running the workflow too, so that we can use it as a subworkflow e.g. in the update cart workflows +export const refreshPaymentCollectionForCartStepId = + "refresh-payment-collection-for-cart" +export const refreshPaymentCollectionForCartStep = createStep( + refreshPaymentCollectionForCartStepId, + async (data: StepInput, { container }) => { + await refreshPaymentCollectionForCartWorkflow(container).run({ + input: { + cart_id: data.cart_id, + }, + }) + + return new StepResponse(null) + } +) + +export const refreshPaymentCollectionForCartWorkflowId = + "refresh-payment-collection-for-cart" +export const refreshPaymentCollectionForCartWorkflow = createWorkflow( + refreshPaymentCollectionForCartWorkflowId, + (input: WorkflowData): WorkflowData => { + const carts = useRemoteQueryStep({ + entry_point: "cart", + fields: [ + "id", + "total", + "currency_code", + "payment_collection.id", + "payment_collection.payment_sessions.id", + ], + variables: { id: input.cart_id }, + }) + + deletePaymentSessionStep({ + payment_session_id: carts[0].payment_collection.payment_sessions?.[0].id, + }) + + // TODO: Temporary fixed cart total, so we can test the workflow. + // This will be removed when the totals utilities are built. + const cartTotal = 4242 + + updatePaymentCollectionStep({ + selector: { id: carts[0].payment_collection.id }, + update: { + amount: cartTotal, + currency_code: carts[0].currency_code, + }, + }) + } +) diff --git a/packages/core-flows/src/definition/cart/workflows/update-cart.ts b/packages/core-flows/src/definition/cart/workflows/update-cart.ts index 4030f4b3b7..01bea38343 100644 --- a/packages/core-flows/src/definition/cart/workflows/update-cart.ts +++ b/packages/core-flows/src/definition/cart/workflows/update-cart.ts @@ -14,6 +14,7 @@ import { updateCartsStep, } from "../steps" import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions" +import { refreshPaymentCollectionForCartStep } from "./refresh-payment-collection" export const updateCartWorkflowId = "update-cart" export const updateCartWorkflow = createWorkflow( @@ -68,6 +69,10 @@ export const updateCartWorkflow = createWorkflow( action: PromotionActions.REPLACE, }) + refreshPaymentCollectionForCartStep({ + cart_id: input.id, + }) + const retrieveCartInput = { id: input.id, config: { diff --git a/packages/core-flows/src/definition/payment-collection/steps/delete-payment-session.ts b/packages/core-flows/src/definition/payment-collection/steps/delete-payment-session.ts new file mode 100644 index 0000000000..457590205f --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/steps/delete-payment-session.ts @@ -0,0 +1,46 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPaymentModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + payment_session_id?: string +} + +export const deletePaymentSessionStepId = "delete-payment-session" +export const deletePaymentSessionStep = createStep( + deletePaymentSessionStepId, + async (input: StepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + if (!input.payment_session_id) { + return new StepResponse(void 0, null) + } + + const [session] = await service.listPaymentSessions({ + id: input.payment_session_id, + }) + + await service.deletePaymentSession(input.payment_session_id) + + return new StepResponse(input.payment_session_id, session) + }, + async (input, { container }) => { + const service = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + if (!input || !input.payment_collection) { + return + } + + await service.createPaymentSession(input.payment_collection.id, { + provider_id: input.provider_id, + currency_code: input.currency_code, + amount: input.amount, + data: input.data ?? {}, + context: input.context, + }) + } +) diff --git a/packages/core-flows/src/definition/payment-collection/steps/index.ts b/packages/core-flows/src/definition/payment-collection/steps/index.ts index 01c6dc3cff..98fc3b72d5 100644 --- a/packages/core-flows/src/definition/payment-collection/steps/index.ts +++ b/packages/core-flows/src/definition/payment-collection/steps/index.ts @@ -1,3 +1,5 @@ export * from "./create-payment-session" +export * from "./delete-payment-session" export * from "./retrieve-payment-collection" +export * from "./update-payment-collection" diff --git a/packages/core-flows/src/definition/payment-collection/steps/update-payment-collection.ts b/packages/core-flows/src/definition/payment-collection/steps/update-payment-collection.ts new file mode 100644 index 0000000000..46cd2bdfc8 --- /dev/null +++ b/packages/core-flows/src/definition/payment-collection/steps/update-payment-collection.ts @@ -0,0 +1,59 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + FilterablePaymentCollectionProps, + IPaymentModuleService, + PaymentCollectionUpdatableFields, +} from "@medusajs/types" +import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + selector: FilterablePaymentCollectionProps + update: PaymentCollectionUpdatableFields +} + +export const updatePaymentCollectionStepId = "update-payment-collection" +export const updatePaymentCollectionStep = createStep( + updatePaymentCollectionStepId, + async (data: StepInput, { container }) => { + const paymentModuleService = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + + const prevData = await paymentModuleService.listPaymentCollections( + data.selector, + { + select: selects, + relations, + } + ) + + const updated = await paymentModuleService.updatePaymentCollections( + data.selector, + data.update + ) + + return new StepResponse(updated, prevData) + }, + async (prevData, { container }) => { + if (!prevData) { + return + } + const paymentModuleService = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + await paymentModuleService.upsertPaymentCollections( + prevData.map((pc) => ({ + id: pc.id, + amount: pc.amount, + currency_code: pc.currency_code, + metadata: pc.metadata, + })) + ) + } +) diff --git a/packages/medusa-test-utils/src/module-test-runner.ts b/packages/medusa-test-utils/src/module-test-runner.ts index bc36114936..758e2959fc 100644 --- a/packages/medusa-test-utils/src/module-test-runner.ts +++ b/packages/medusa-test-utils/src/module-test-runner.ts @@ -1,11 +1,10 @@ -import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils" -import { initModules, InitModulesOptions } from "./init-modules" import { MedusaAppOutput, - MedusaModuleConfig, ModulesDefinition } from "@medusajs/modules-sdk" -import { getDatabaseURL, getMikroOrmWrapper, TestDatabase } from "./database" +import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils" +import { TestDatabase, getDatabaseURL, getMikroOrmWrapper } from "./database" +import { InitModulesOptions, initModules } from "./init-modules" import { MockEventBusService } from "." diff --git a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index 3b03fbbb47..9376a483b1 100644 --- a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -277,8 +277,7 @@ moduleIntegrationTestRunner({ describe("update", () => { it("should update a Payment Collection", async () => { - await service.updatePaymentCollections({ - id: "pay-col-id-2", + await service.updatePaymentCollections("pay-col-id-2", { currency_code: "eur", region_id: "reg-2", }) diff --git a/packages/payment/src/joiner-config.ts b/packages/payment/src/joiner-config.ts index 54b56ed8d5..81f8a9eaea 100644 --- a/packages/payment/src/joiner-config.ts +++ b/packages/payment/src/joiner-config.ts @@ -1,7 +1,12 @@ import { Modules } from "@medusajs/modules-sdk" import { ModuleJoinerConfig } from "@medusajs/types" import { MapToConfig } from "@medusajs/utils" -import { Payment, PaymentCollection, PaymentProvider } from "@models" +import { + Payment, + PaymentCollection, + PaymentProvider, + PaymentSession, +} from "@models" export const LinkableKeys = { payment_id: Payment.name, @@ -37,6 +42,13 @@ export const joinerConfig: ModuleJoinerConfig = { entity: PaymentCollection.name, }, }, + { + name: ["payment_session", "payment_sessions"], + args: { + entity: PaymentSession.name, + methodSuffix: "PaymentSessions", + }, + }, { name: ["payment_provider", "payment_providers"], args: { diff --git a/packages/payment/src/migrations/Migration20240225134525.ts b/packages/payment/src/migrations/Migration20240225134525.ts index 6ab5c96031..6a0d3e4537 100644 --- a/packages/payment/src/migrations/Migration20240225134525.ts +++ b/packages/payment/src/migrations/Migration20240225134525.ts @@ -28,6 +28,7 @@ export class Migration20240225134525 extends Migration { ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "payment_authorized_at" TIMESTAMPTZ NULL; ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL; ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; + ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "context" JSONB NULL; ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "payment_collection_id" TEXT NOT NULL; @@ -161,6 +162,7 @@ export class Migration20240225134525 extends Migration { "raw_amount" JSONB NOT NULL, "provider_id" TEXT NOT NULL, "data" JSONB NOT NULL, + "context" JSONB NULL, "status" TEXT CHECK ("status" IN ('authorized', 'pending', 'requires_more', 'error', 'canceled')) NOT NULL DEFAULT 'pending', "authorized_at" TIMESTAMPTZ NULL, "payment_collection_id" TEXT NOT NULL, diff --git a/packages/payment/src/models/payment-session.ts b/packages/payment/src/models/payment-session.ts index d9b2019ff6..469a1469b2 100644 --- a/packages/payment/src/models/payment-session.ts +++ b/packages/payment/src/models/payment-session.ts @@ -43,6 +43,9 @@ export default class PaymentSession { @Property({ columnType: "jsonb" }) data: Record = {} + @Property({ columnType: "jsonb", nullable: true }) + context: Record | null + @Enum({ items: () => PaymentSessionStatus, }) diff --git a/packages/payment/src/services/payment-module.ts b/packages/payment/src/services/payment-module.ts index b906ade9a3..c44802af22 100644 --- a/packages/payment/src/services/payment-module.ts +++ b/packages/payment/src/services/payment-module.ts @@ -6,6 +6,7 @@ import { CreatePaymentSessionDTO, CreateRefundDTO, DAL, + FilterablePaymentCollectionProps, FilterablePaymentProviderProps, FindConfig, InternalModuleDeclaration, @@ -13,6 +14,7 @@ import { ModuleJoinerConfig, ModulesSdkTypes, PaymentCollectionDTO, + PaymentCollectionUpdatableFields, PaymentDTO, PaymentProviderDTO, PaymentSessionDTO, @@ -22,14 +24,17 @@ import { UpdatePaymentCollectionDTO, UpdatePaymentDTO, UpdatePaymentSessionDTO, + UpsertPaymentCollectionDTO, } from "@medusajs/types" import { InjectManager, InjectTransactionManager, + isString, MedusaContext, MedusaError, ModulesSdkUtils, PaymentActions, + promiseAll, } from "@medusajs/utils" import { Capture, @@ -127,15 +132,14 @@ export default class PaymentModuleService< data: CreatePaymentCollectionDTO[], sharedContext?: Context ): Promise - - @InjectTransactionManager("baseRepository_") + @InjectManager("baseRepository_") async createPaymentCollections( data: CreatePaymentCollectionDTO | CreatePaymentCollectionDTO[], @MedusaContext() sharedContext?: Context ): Promise { const input = Array.isArray(data) ? data : [data] - const collections = await this.paymentCollectionService_.create( + const collections = await this.createPaymentCollections_( input, sharedContext ) @@ -148,23 +152,54 @@ export default class PaymentModuleService< ) } + @InjectTransactionManager("baseRepository_") + async createPaymentCollections_( + data: CreatePaymentCollectionDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + return this.paymentCollectionService_.create(data, sharedContext) + } + updatePaymentCollections( - data: UpdatePaymentCollectionDTO[], - sharedContext?: Context - ): Promise - updatePaymentCollections( - data: UpdatePaymentCollectionDTO, + paymentCollectionId: string, + data: PaymentCollectionUpdatableFields, sharedContext?: Context ): Promise - - @InjectTransactionManager("baseRepository_") - async updatePaymentCollections( - data: UpdatePaymentCollectionDTO | UpdatePaymentCollectionDTO[], + updatePaymentCollections( + selector: FilterablePaymentCollectionProps, + data: PaymentCollectionUpdatableFields, sharedContext?: Context + ): Promise + @InjectManager("baseRepository_") + async updatePaymentCollections( + idOrSelector: string | FilterablePaymentCollectionProps, + data: PaymentCollectionUpdatableFields, + @MedusaContext() sharedContext?: Context ): Promise { - const input = Array.isArray(data) ? data : [data] - const result = await this.paymentCollectionService_.update( - input, + let updateData: UpdatePaymentCollectionDTO[] = [] + + if (isString(idOrSelector)) { + updateData = [ + { + id: idOrSelector, + ...data, + }, + ] + } else { + const collections = await this.paymentCollectionService_.list( + idOrSelector, + {}, + sharedContext + ) + + updateData = collections.map((c) => ({ + id: c.id, + ...data, + })) + } + + const result = await this.updatePaymentCollections_( + updateData, sharedContext ) @@ -176,6 +211,52 @@ export default class PaymentModuleService< ) } + @InjectTransactionManager("baseRepository_") + async updatePaymentCollections_( + data: UpdatePaymentCollectionDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + return await this.paymentCollectionService_.update(data, sharedContext) + } + + upsertPaymentCollections( + data: UpsertPaymentCollectionDTO[], + sharedContext?: Context + ): Promise + upsertPaymentCollections( + data: UpsertPaymentCollectionDTO, + sharedContext?: Context + ): Promise + + @InjectTransactionManager("baseRepository_") + async upsertPaymentCollections( + data: UpsertPaymentCollectionDTO | UpsertPaymentCollectionDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + const input = Array.isArray(data) ? data : [data] + const forUpdate = input.filter( + (collection): collection is UpdatePaymentCollectionDTO => !!collection.id + ) + const forCreate = input.filter( + (collection): collection is CreatePaymentCollectionDTO => !collection.id + ) + + const operations: Promise[] = [] + + if (forCreate.length) { + operations.push(this.createPaymentCollections_(forCreate, sharedContext)) + } + if (forUpdate.length) { + operations.push(this.updatePaymentCollections_(forUpdate, sharedContext)) + } + + const result = (await promiseAll(operations)).flat() + + return await this.baseRepository_.serialize< + PaymentCollectionDTO[] | PaymentCollectionDTO + >(Array.isArray(data) ? result : result[0]) + } + completePaymentCollections( paymentCollectionId: string, sharedContext?: Context diff --git a/packages/types/src/payment/common.ts b/packages/types/src/payment/common.ts index 4c2b8b0957..0b3b8c48a2 100644 --- a/packages/types/src/payment/common.ts +++ b/packages/types/src/payment/common.ts @@ -114,7 +114,7 @@ export interface PaymentCollectionDTO { /** * Holds custom data in key-value pairs */ - metadata?: Record | null + metadata?: Record /** * The status of the payment collection @@ -417,6 +417,11 @@ export interface PaymentSessionDTO { */ data: Record + /** + * Payment session context + */ + context?: Record + /** * The status of the payment session */ diff --git a/packages/types/src/payment/mutations.ts b/packages/types/src/payment/mutations.ts index 6d4b109ca3..87e2d6c31f 100644 --- a/packages/types/src/payment/mutations.ts +++ b/packages/types/src/payment/mutations.ts @@ -1,4 +1,3 @@ -import { PaymentCollectionStatus } from "./common" import { PaymentProviderContext } from "./provider" /** @@ -35,21 +34,24 @@ export interface UpdatePaymentCollectionDTO * The ID of the payment collection. */ id: string +} - /** - * The authorized amount of the payment collection. - */ - authorized_amount?: number +export interface UpsertPaymentCollectionDTO { + id?: string + region_id?: string + currency_code?: string + amount?: number + metadata?: Record +} - /** - * The refunded amount of the payment collection. - */ - refunded_amount?: number - - /** - * The status of the payment collection. - */ - status?: PaymentCollectionStatus +/** + * The attributes to update in the payment collection. + */ +export interface PaymentCollectionUpdatableFields { + region_id?: string + currency_code?: string + amount?: number + metadata?: Record } /** diff --git a/packages/types/src/payment/service.ts b/packages/types/src/payment/service.ts index 0a73402d0f..4124c5a64a 100644 --- a/packages/types/src/payment/service.ts +++ b/packages/types/src/payment/service.ts @@ -20,10 +20,11 @@ import { CreatePaymentCollectionDTO, CreatePaymentSessionDTO, CreateRefundDTO, + PaymentCollectionUpdatableFields, ProviderWebhookPayload, - UpdatePaymentCollectionDTO, UpdatePaymentDTO, UpdatePaymentSessionDTO, + UpsertPaymentCollectionDTO, } from "./mutations" /** @@ -164,33 +165,23 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise<[PaymentCollectionDTO[], number]> - /** - * This method updates existing payment collections. - * - * @param {UpdatePaymentCollectionDTO[]} data - The attributes to update in payment collections. - * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. - * @returns {Promise} The updated payment collections. - * - * @example - * {example-code} - */ updatePaymentCollections( - data: UpdatePaymentCollectionDTO[], + paymentCollectionId: string, + data: PaymentCollectionUpdatableFields, + sharedContext?: Context + ): Promise + updatePaymentCollections( + selector: FilterablePaymentCollectionProps, + data: PaymentCollectionUpdatableFields, sharedContext?: Context ): Promise - /** - * This method updates an existing payment collection. - * - * @param {UpdatePaymentCollectionDTO} data - The attributes to update in a payment collection. - * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. - * @returns {Promise} The updated payment collection. - * - * @example - * {example-code} - */ - updatePaymentCollections( - data: UpdatePaymentCollectionDTO, + upsertPaymentCollections( + data: UpsertPaymentCollectionDTO[], + sharedContext?: Context + ): Promise + upsertPaymentCollections( + data: UpsertPaymentCollectionDTO, sharedContext?: Context ): Promise diff --git a/packages/utils/src/common/__tests__/deep-copy.spec.ts b/packages/utils/src/common/__tests__/deep-copy.spec.ts new file mode 100644 index 0000000000..051fdae9e5 --- /dev/null +++ b/packages/utils/src/common/__tests__/deep-copy.spec.ts @@ -0,0 +1,74 @@ +import { deepCopy } from "../deep-copy" + +class TestA { + prop1: any + prop2: any + + constructor(prop1: any, prop2: any) { + this.prop1 = prop1 + this.prop2 = prop2 + } +} + +class TestWrapper { + prop1: any + prop2: any + + constructor(prop1: any, prop2: any) { + this.prop1 = prop1 + this.prop2 = prop2 + } + + factory() { + return new TestA(deepCopy(this.prop1), deepCopy(this.prop2)) + } +} + +class TestWrapperWithoutDeepCopy { + prop1: any + prop2: any + + constructor(prop1: any, prop2: any) { + this.prop1 = prop1 + this.prop2 = prop2 + } + + factory() { + return new TestA(this.prop1, this.prop2) + } +} + +describe("deepCopy", () => { + it("should deep copy an object", () => { + const prop1 = { + prop1: 1, + } + + const prop2 = { + prop1: 3, + } + + const wrapperWithoutDeepCopy = new TestWrapperWithoutDeepCopy(prop1, prop2) + let factory1 = wrapperWithoutDeepCopy.factory() + let factory2 = wrapperWithoutDeepCopy.factory() + + factory1.prop1.prop1 = 2 + + expect(wrapperWithoutDeepCopy.prop1).toEqual({ prop1: 2 }) + expect(factory1.prop1).toEqual({ prop1: 2 }) + expect(factory2.prop1).toEqual({ prop1: 2 }) + + prop1.prop1 = 4 + prop2.prop1 = 4 + + const wrapper = new TestWrapper(prop1, prop2) + factory1 = wrapper.factory() + factory2 = wrapper.factory() + + factory1.prop1.prop1 = 2 + + expect(wrapper.prop1).toEqual({ prop1: 4 }) + expect(factory1.prop1).toEqual({ prop1: 2 }) + expect(factory2.prop1).toEqual({ prop1: 4 }) + }) +}) diff --git a/packages/utils/src/common/deep-copy.ts b/packages/utils/src/common/deep-copy.ts new file mode 100644 index 0000000000..a22431b778 --- /dev/null +++ b/packages/utils/src/common/deep-copy.ts @@ -0,0 +1,40 @@ +import { isObject } from "./is-object" + +/** + * In most casees, JSON.parse(JSON.stringify(obj)) is enough to deep copy an object. + * But in some cases, it's not enough. For example, if the object contains a function or a proxy, it will be lost after JSON.parse(JSON.stringify(obj)). + * Furthermore, structuredClone is not present in all environments, such as with jest so we need to use a custom deepCopy function. + * + * @param obj + */ +export function deepCopy = Record>( + obj: T | T[] +): T | T[] { + if (typeof structuredClone != "undefined") { + return structuredClone(obj) + } + + if (obj === null || typeof obj !== "object") { + return obj + } + + if (Array.isArray(obj)) { + const copy: any[] = [] + for (let i = 0; i < obj.length; i++) { + copy[i] = deepCopy(obj[i]) + } + return copy + } + + if (isObject(obj)) { + const copy: Record = {} + for (let attr in obj) { + if (obj.hasOwnProperty(attr)) { + copy[attr] = deepCopy(obj[attr]) + } + } + return copy + } + + return obj +} diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index ca8de7ae85..140bd8a36f 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -8,6 +8,7 @@ export * from "./create-container-like" export * from "./create-psql-index-helper" export * from "./deduplicate" export * from "./deep-equal-obj" +export * from "./deep-copy" export * from "./errors" export * from "./generate-entity-id" export * from "./generate-linkable-keys-map" diff --git a/packages/utils/src/dal/mikro-orm/utils.ts b/packages/utils/src/dal/mikro-orm/utils.ts index 33fb2bbc09..693d8dda31 100644 --- a/packages/utils/src/dal/mikro-orm/utils.ts +++ b/packages/utils/src/dal/mikro-orm/utils.ts @@ -149,7 +149,7 @@ export const mikroOrmSerializer = async ( ): Promise => { options ??= {} - const data_ = Array.isArray(data) ? data : [data] + const data_ = (Array.isArray(data) ? data : [data]).filter(Boolean) const forSerialization: unknown[] = [] const notForSerialization: unknown[] = [] diff --git a/packages/workflows-sdk/src/utils/composer/helpers/resolve-value.ts b/packages/workflows-sdk/src/utils/composer/helpers/resolve-value.ts index 0fdf7d2881..73b5000de7 100644 --- a/packages/workflows-sdk/src/utils/composer/helpers/resolve-value.ts +++ b/packages/workflows-sdk/src/utils/composer/helpers/resolve-value.ts @@ -1,4 +1,4 @@ -import { OrchestrationUtils, promiseAll } from "@medusajs/utils" +import { OrchestrationUtils, deepCopy, promiseAll } from "@medusajs/utils" async function resolveProperty(property, transactionContext) { const { invoke: invokeRes } = transactionContext @@ -31,6 +31,8 @@ async function resolveProperty(property, transactionContext) { * @internal */ export async function resolveValue(input, transactionContext) { + const copiedInput = deepCopy(input) + const unwrapInput = async ( inputTOUnwrap: Record, parentRef: any @@ -63,9 +65,9 @@ export async function resolveValue(input, transactionContext) { return parentRef } - const result = input?.__type - ? await resolveProperty(input, transactionContext) - : await unwrapInput(input, {}) + const result = copiedInput?.__type + ? await resolveProperty(copiedInput, transactionContext) + : await unwrapInput(copiedInput, {}) return result && JSON.parse(JSON.stringify(result)) }