From 8c57e61cb89f46bf05815bfe2bc6751336f595bc Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Thu, 7 Mar 2024 14:32:20 +0100 Subject: [PATCH] feat: Refresh payment collection + delete session (#6594) ### What Add workflow for refreshing a payment collection. The idea is that on all cart updates, we want two things to happen (in the context of payments) 1. the currently active payment sessions should be destroyed, and 2. the payment collection should be updated with the new cart total. We do this to ensure that we always collect the correct payment amount. From a customer perspective, this would mean that every time something on the cart is updated, the customer would need to enter their payment details anew. To me, this is a good tradeoff to avoid inconsistencies with payment collection. Additionally, I updated the Payment Module interface with `upsert` and `updated` following our established convention. ### Note This PR depends on a fix to the `remoteJoiner` that @carlos-r-l-rodrigues is working on. Update: Fix merged in #6602 Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com> --- .../cart/store/cart.workflows.spec.ts | 382 +++++++++++++----- integration-tests/modules/package.json | 2 +- .../src/common/steps/use-remote-query.ts | 26 ++ .../src/definition/cart/workflows/index.ts | 1 + .../workflows/refresh-payment-collection.ts | 70 ++++ .../definition/cart/workflows/update-cart.ts | 5 + .../steps/delete-payment-session.ts | 46 +++ .../payment-collection/steps/index.ts | 2 + .../steps/update-payment-collection.ts | 59 +++ .../src/module-test-runner.ts | 7 +- .../services/payment-module/index.spec.ts | 3 +- packages/payment/src/joiner-config.ts | 14 +- .../src/migrations/Migration20240225134525.ts | 2 + .../payment/src/models/payment-session.ts | 3 + .../payment/src/services/payment-module.ts | 111 ++++- packages/types/src/payment/common.ts | 7 +- packages/types/src/payment/mutations.ts | 30 +- packages/types/src/payment/service.ts | 39 +- .../src/common/__tests__/deep-copy.spec.ts | 74 ++++ packages/utils/src/common/deep-copy.ts | 40 ++ packages/utils/src/common/index.ts | 1 + packages/utils/src/dal/mikro-orm/utils.ts | 2 +- .../utils/composer/helpers/resolve-value.ts | 10 +- 23 files changed, 763 insertions(+), 173 deletions(-) create mode 100644 packages/core-flows/src/common/steps/use-remote-query.ts create mode 100644 packages/core-flows/src/definition/cart/workflows/refresh-payment-collection.ts create mode 100644 packages/core-flows/src/definition/payment-collection/steps/delete-payment-session.ts create mode 100644 packages/core-flows/src/definition/payment-collection/steps/update-payment-collection.ts create mode 100644 packages/utils/src/common/__tests__/deep-copy.spec.ts create mode 100644 packages/utils/src/common/deep-copy.ts 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)) }