From cac81749eaa06b3b00ac5494591c96a0fcd7bf57 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Tue, 10 Jan 2023 16:33:24 +0100 Subject: [PATCH] feat(medusa): Update payment session management (#2937) --- .changeset/swift-kiwis-relate.md | 5 + .../api/routes/store/carts/complete-cart.ts | 2 +- .../medusa/src/interfaces/payment-service.ts | 3 +- ...2906846560-payment-session-is-initiated.ts | 24 ++ packages/medusa/src/models/payment-session.ts | 18 +- .../medusa/src/services/__tests__/cart.js | 244 ++++++++++---- .../services/__tests__/payment-provider.js | 2 +- packages/medusa/src/services/cart.ts | 300 ++++++++++++------ .../medusa/src/services/payment-provider.ts | 68 ++-- packages/medusa/src/subscribers/cart.ts | 38 +-- packages/medusa/src/types/payment.ts | 1 + 11 files changed, 464 insertions(+), 241 deletions(-) create mode 100644 .changeset/swift-kiwis-relate.md create mode 100644 packages/medusa/src/migrations/1672906846560-payment-session-is-initiated.ts diff --git a/.changeset/swift-kiwis-relate.md b/.changeset/swift-kiwis-relate.md new file mode 100644 index 0000000000..f61be4b226 --- /dev/null +++ b/.changeset/swift-kiwis-relate.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +chore: Update cart payment session management diff --git a/packages/medusa/src/api/routes/store/carts/complete-cart.ts b/packages/medusa/src/api/routes/store/carts/complete-cart.ts index cf3ea3e747..b1e1e870ca 100644 --- a/packages/medusa/src/api/routes/store/carts/complete-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/complete-cart.ts @@ -1,6 +1,6 @@ import { EntityManager } from "typeorm" import { AbstractCartCompletionStrategy } from "../../../../interfaces" -import { IdempotencyKey } from "../../../../models/idempotency-key" +import { IdempotencyKey } from "../../../../models" import { IdempotencyKeyService } from "../../../../services" /** diff --git a/packages/medusa/src/interfaces/payment-service.ts b/packages/medusa/src/interfaces/payment-service.ts index d4cbf266db..bf13bacd2b 100644 --- a/packages/medusa/src/interfaces/payment-service.ts +++ b/packages/medusa/src/interfaces/payment-service.ts @@ -195,11 +195,12 @@ export abstract class AbstractPaymentService * @param paymentSessionData * @param context The type of this argument is meant to be temporary and once the previous method signature * will be removed, the type will only be PaymentContext instead of Cart & PaymentContext + * @return it return either a PaymentSessionResponse or PaymentSessionResponse["session_data"] to maintain backward compatibility */ public abstract updatePayment( paymentSessionData: PaymentSessionData, context: Cart & PaymentContext - ): Promise + ): Promise /** * @deprecated use updatePayment(paymentSessionData: PaymentSessionData, context: Cart & PaymentContext): Promise instead diff --git a/packages/medusa/src/migrations/1672906846560-payment-session-is-initiated.ts b/packages/medusa/src/migrations/1672906846560-payment-session-is-initiated.ts new file mode 100644 index 0000000000..aef377903d --- /dev/null +++ b/packages/medusa/src/migrations/1672906846560-payment-session-is-initiated.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class PaymentSessionIsInitiated1672906846560 implements MigrationInterface { + name = "paymentSessionIsInitiated1672906846560" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE payment_session ADD COLUMN is_initiated BOOLEAN NOT NULL DEFAULT false + `) + + // Set is_initiated to true if there is more that 0 key in the data. We assume that if data contains any key + // A payment has been initiated to the payment provider + await queryRunner.query(` + UPDATE payment_session SET is_initiated = true WHERE ( + SELECT coalesce(json_array_length(json_agg(keys)), 0) + FROM jsonb_object_keys(data) AS keys (keys) + ) > 0 + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE payment_session DROP COLUMN is_initiated`) + } +} diff --git a/packages/medusa/src/models/payment-session.ts b/packages/medusa/src/models/payment-session.ts index 70d5aeb042..beb3e35981 100644 --- a/packages/medusa/src/models/payment-session.ts +++ b/packages/medusa/src/models/payment-session.ts @@ -1,12 +1,4 @@ -import { - BeforeInsert, - Column, - Entity, - Index, - JoinColumn, - ManyToOne, - Unique, -} from "typeorm" +import { BeforeInsert, Column, Entity, Index, JoinColumn, ManyToOne, Unique, } from "typeorm" import { BaseEntity } from "../interfaces" import { Cart } from "./cart" @@ -43,6 +35,9 @@ export class PaymentSession extends BaseEntity { @Column({ type: "boolean", nullable: true }) is_selected: boolean | null + @Column({ type: "boolean", default: false }) + is_initiated: boolean + @DbAwareColumn({ type: "enum", enum: PaymentSessionStatus }) status: string @@ -97,6 +92,11 @@ export class PaymentSession extends BaseEntity { * description: "A flag to indicate if the Payment Session has been selected as the method that will be used to complete the purchase." * type: boolean * example: true + * is_initiated: + * description: "A flag to indicate if a communication with the third party provider has been initiated." + * type: boolean + * example: true + * default: false * status: * description: "Indicates the status of the Payment Session. Will default to `pending`, and will eventually become `authorized`. Payment Sessions may have the status of `requires_more` to indicate that further actions are to be completed by the Customer." * type: string diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index ac18bc1bd6..8d149da739 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -7,6 +7,7 @@ import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment" import { newTotalsServiceMock } from "../__mocks__/new-totals" import { taxProviderServiceMock } from "../__mocks__/tax-provider" +import { PaymentSessionStatus } from "../../models" const eventBusService = { emit: jest.fn(), @@ -1359,32 +1360,71 @@ describe("CartService", () => { describe("setPaymentSession", () => { const cartRepository = MockRepository({ - findOneWithRelations: () => { - return Promise.resolve({ - region: { - payment_providers: [ + findOneWithRelations: (rels, q) => { + if (q.where.id === IdMap.getId("cartWithLine")) { + return Promise.resolve({ + total: 100, + customer: {}, + region: { + currency_code: "usd", + payment_providers: [ + { + id: "test-provider", + }, + ], + }, + items: [], + shipping_methods: [], + payment_sessions: [ { - id: "test-provider", + id: IdMap.getId("test-session"), + provider_id: "test-provider", }, ], - }, - items: [], - shipping_methods: [], - payment_sessions: [ - { - id: IdMap.getId("test-session"), - provider_id: "test-provider", + }) + } else if (q.where.id === IdMap.getId("cartWithLine2")) { + return Promise.resolve({ + total: 100, + customer: {}, + region: { + currency_code: "usd", + payment_providers: [ + { + id: "test-provider", + }, + ], }, - ], - }) + items: [], + shipping_methods: [], + payment_sessions: [ + { + id: IdMap.getId("test-session"), + provider_id: "test-provider", + is_selected: true, + }, + ], + }) + } }, }) const paymentSessionRepository = MockRepository({}) + const paymentProviderService = { + deleteSession: jest.fn(), + updateSession: jest.fn(), + createSession: jest.fn().mockImplementation(() => { + return { id: IdMap.getId("test-session") } + }), + withTransaction: function () { + return this + }, + } + const cartService = new CartService({ manager: MockManager, paymentSessionRepository, + paymentProviderService, totalsService, cartRepository, eventBusService, @@ -1397,22 +1437,63 @@ describe("CartService", () => { jest.clearAllMocks() }) - it("successfully sets a payment method", async () => { + it("successfully sets a payment method and create it remotely", async () => { + const providerId = "test-provider" + await cartService.setPaymentSession( IdMap.getId("cartWithLine"), - "test-provider" + providerId ) expect(eventBusService.emit).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledWith( - "cart.updated", + CartService.Events.UPDATED, expect.any(Object) ) - expect(paymentSessionRepository.save).toHaveBeenCalledWith({ - id: IdMap.getId("test-session"), - provider_id: "test-provider", - is_selected: true, + + expect(paymentProviderService.createSession).toHaveBeenCalledWith({ + cart: expect.any(Object), + customer: expect.any(Object), + amount: expect.any(Number), + currency_code: expect.any(String), + provider_id: providerId, + payment_session_id: IdMap.getId("test-session"), }) + expect(paymentSessionRepository.update).toHaveBeenCalledWith( + IdMap.getId("test-session"), + { + is_selected: true, + } + ) + }) + + it("successfully sets a payment method and update it remotely", async () => { + const providerId = "test-provider" + + await cartService.setPaymentSession( + IdMap.getId("cartWithLine2"), + providerId + ) + + expect(eventBusService.emit).toHaveBeenCalledTimes(1) + expect(eventBusService.emit).toHaveBeenCalledWith( + CartService.Events.UPDATED, + expect.any(Object) + ) + + expect(paymentProviderService.updateSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: IdMap.getId("test-session"), + }), + { + cart: expect.any(Object), + customer: expect.any(Object), + amount: expect.any(Number), + currency_code: expect.any(String), + provider_id: providerId, + payment_session_id: IdMap.getId("test-session"), + } + ) }) it("fails if the region does not contain the provider_id", async () => { @@ -1423,13 +1504,16 @@ describe("CartService", () => { }) describe("setPaymentSessions", () => { + const provider1Id = "provider_1" + const provider2Id = "provider_2" + const cart1 = { total: 100, items: [{ subtotal: 100 }], shipping_methods: [], payment_sessions: [], region: { - payment_providers: [{ id: "provider_1" }, { id: "provider_2" }], + payment_providers: [{ id: provider1Id }, { id: provider2Id }], }, } @@ -1437,9 +1521,9 @@ describe("CartService", () => { total: 100, items: [], shipping_methods: [], - payment_sessions: [{ provider_id: "provider_1" }], + payment_sessions: [{ provider_id: provider1Id }], region: { - payment_providers: [{ id: "provider_1" }, { id: "provider_2" }], + payment_providers: [{ id: provider1Id }, { id: provider2Id }], }, } @@ -1448,11 +1532,11 @@ describe("CartService", () => { items: [{ subtotal: 100 }], shipping_methods: [{ subtotal: 100 }], payment_sessions: [ - { provider_id: "provider_1" }, + { provider_id: provider1Id }, { provider_id: "not_in_region" }, ], region: { - payment_providers: [{ id: "provider_1" }, { id: "provider_2" }], + payment_providers: [{ id: provider1Id }, { id: provider2Id }], }, } @@ -1461,22 +1545,24 @@ describe("CartService", () => { items: [{ total: 0 }], shipping_methods: [], payment_sessions: [ - { provider_id: "provider_1" }, - { provider_id: "provider_2" }, + { provider_id: provider1Id }, + { provider_id: provider2Id }, ], region: { - payment_providers: [{ id: "provider_1" }, { id: "provider_2" }], + payment_providers: [{ id: provider1Id }, { id: provider2Id }], }, } const cart5 = { - total: -1, + total: 100, + items: [{ subtotal: 100 }], + shipping_methods: [], payment_sessions: [ - { provider_id: "provider_1" }, - { provider_id: "provider_2" }, + { provider_id: provider1Id, is_initiated: true }, + { provider_id: provider2Id, is_selected: true }, ], region: { - payment_providers: [{ id: "provider_1" }, { id: "provider_2" }], + payment_providers: [{ id: provider1Id }, { id: provider2Id }], }, } @@ -1494,6 +1580,11 @@ describe("CartService", () => { if (q.where.id === IdMap.getId("cart-negative")) { return Promise.resolve(cart4) } + if ( + q.where.id === IdMap.getId("cartWithMixedSelectedInitiatedSessions") + ) { + return Promise.resolve(cart5) + } return Promise.resolve(cart1) }, }) @@ -1507,8 +1598,11 @@ describe("CartService", () => { }, } + const paymentSessionRepositoryMock = MockRepository({}) + const cartService = new CartService({ manager: MockManager, + paymentSessionRepository: paymentSessionRepositoryMock, totalsService, cartRepository, paymentProviderService, @@ -1525,30 +1619,58 @@ describe("CartService", () => { it("initializes payment sessions for each of the providers", async () => { await cartService.setPaymentSessions(IdMap.getId("cartWithLine")) - expect(paymentProviderService.createSession).toHaveBeenCalledTimes(2) - expect(paymentProviderService.createSession).toHaveBeenCalledWith({ - cart: cart1, - customer: cart1.customer, + expect(paymentSessionRepositoryMock.create).toHaveBeenCalledTimes(2) + expect(paymentSessionRepositoryMock.save).toHaveBeenCalledTimes(2) + + expect(paymentSessionRepositoryMock.create).toHaveBeenCalledWith({ + cart_id: IdMap.getId("cartWithLine"), + status: PaymentSessionStatus.PENDING, amount: cart1.total, - currency_code: cart1.region.currency_code, - provider_id: "provider_1", + provider_id: provider1Id, + data: {}, }) - expect(paymentProviderService.createSession).toHaveBeenCalledWith({ - cart: cart1, - customer: cart1.customer, + + expect(paymentSessionRepositoryMock.create).toHaveBeenCalledWith({ + cart_id: IdMap.getId("cartWithLine"), + status: PaymentSessionStatus.PENDING, amount: cart1.total, - currency_code: cart1.region.currency_code, - provider_id: "provider_2", + provider_id: provider2Id, + data: {}, }) }) + it("delete or update payment sessions remotely depending if they are selected and/or initiated", async () => { + await cartService.setPaymentSessions( + IdMap.getId("cartWithMixedSelectedInitiatedSessions") + ) + + // Selected, update + expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(1) + expect(paymentProviderService.updateSession).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + provider_id: provider2Id, + }) + ) + + // Not selected, but initiated, delete + expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(1) + expect(paymentProviderService.deleteSession).toHaveBeenCalledWith( + expect.objectContaining({ + provider_id: provider1Id, + }) + ) + + expect(paymentSessionRepositoryMock.save).toHaveBeenCalledTimes(1) + }) + it("filters sessions not available in the region", async () => { await cartService.setPaymentSessions(IdMap.getId("cart-to-filter")) - expect(paymentProviderService.createSession).toHaveBeenCalledTimes(1) - expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(1) - expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(1) - expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({ + expect(paymentSessionRepositoryMock.create).toHaveBeenCalledTimes(1) + expect(paymentSessionRepositoryMock.save).toHaveBeenCalledTimes(2) // create and update + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledTimes(1) + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({ provider_id: "not_in_region", }) }) @@ -1556,28 +1678,26 @@ describe("CartService", () => { it("removes if cart total === 0", async () => { await cartService.setPaymentSessions(IdMap.getId("cart-remove")) - expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(0) - expect(paymentProviderService.createSession).toHaveBeenCalledTimes(0) - expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(2) - expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({ - provider_id: "provider_1", + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledTimes(2) + + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({ + provider_id: provider1Id, }) - expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({ - provider_id: "provider_2", + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({ + provider_id: provider2Id, }) }) it("removes if cart total < 0", async () => { await cartService.setPaymentSessions(IdMap.getId("cart-negative")) - expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(0) - expect(paymentProviderService.createSession).toHaveBeenCalledTimes(0) - expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(2) - expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({ - provider_id: "provider_1", + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledTimes(2) + + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({ + provider_id: provider1Id, }) - expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({ - provider_id: "provider_2", + expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({ + provider_id: provider2Id, }) }) }) diff --git a/packages/medusa/src/services/__tests__/payment-provider.js b/packages/medusa/src/services/__tests__/payment-provider.js index 99a3b26eb4..b2118c7fc1 100644 --- a/packages/medusa/src/services/__tests__/payment-provider.js +++ b/packages/medusa/src/services/__tests__/payment-provider.js @@ -95,7 +95,7 @@ describe("PaymentProviderService", () => { withTransaction: function () { return this }, - updatePayment: jest.fn().mockReturnValue(Promise.resolve()), + updatePayment: jest.fn().mockReturnValue(Promise.resolve({})), }) ) diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index ba6f9157cd..034837fb6d 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -12,6 +12,7 @@ import { DiscountRuleType, LineItem, PaymentSession, + PaymentSessionStatus, SalesChannel, ShippingMethod, } from "../models" @@ -39,24 +40,24 @@ import { FlagRouter } from "../utils/flag-router" import { validateEmail } from "../utils/is-email" import { PaymentSessionInput } from "../types/payment" import { - CustomShippingOptionService, CustomerService, + CustomShippingOptionService, DiscountService, EventBusService, GiftCardService, - LineItemService, LineItemAdjustmentService, + LineItemService, NewTotalsService, PaymentProviderService, ProductService, - ProductVariantService, ProductVariantInventoryService, + ProductVariantService, RegionService, + SalesChannelService, ShippingOptionService, StoreService, TaxProviderService, TotalsService, - SalesChannelService, } from "." type InjectedDependencies = { @@ -1631,12 +1632,11 @@ class CartService extends TransactionBaseService { } /** - * Sets a payment method for a cart. + * Selects a payment session for a cart and creates a payment object in the external provider system * @param cartId - the id of the cart to add payment method to * @param providerId - the id of the provider to be set to the cart - * @return result of update operation */ - async setPaymentSession(cartId: string, providerId: string): Promise { + async setPaymentSession(cartId: string, providerId: string): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const psRepo = transactionManager.getCustomRepository( @@ -1644,30 +1644,34 @@ class CartService extends TransactionBaseService { ) const cart = await this.retrieveWithTotals(cartId, { - relations: ["region", "region.payment_providers", "payment_sessions"], + relations: [ + "customer", + "region", + "region.payment_providers", + "payment_sessions", + ], }) - // The region must have the provider id in its providers array - if ( - providerId !== "system" && - !( - cart.region.payment_providers.length && - cart.region.payment_providers.find(({ id }) => providerId === id) - ) - ) { + const isProviderPresent = cart.region.payment_providers.find( + ({ id }) => providerId === id + ) + + if (providerId !== "system" && !isProviderPresent) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, `The payment method is not available in this region` ) } - await Promise.all( - cart.payment_sessions.map(async (paymentSession) => { - return psRepo.save({ ...paymentSession, is_selected: null }) - }) + const cartPaymentSessionIds = cart.payment_sessions.map((p) => p.id) + await psRepo.update( + { id: In(cartPaymentSessionIds) }, + { + is_selected: null, + } ) - const paymentSession = cart.payment_sessions.find( + let paymentSession = cart.payment_sessions.find( (ps) => ps.provider_id === providerId ) @@ -1678,16 +1682,34 @@ class CartService extends TransactionBaseService { ) } - paymentSession.is_selected = true + const sessionInput: PaymentSessionInput = { + cart, + customer: cart.customer, + amount: cart.total!, + currency_code: cart.region.currency_code, + provider_id: providerId, + payment_session_id: paymentSession.id, + } - await psRepo.save(paymentSession) + if (paymentSession.is_selected) { + // update the session remotely + await this.paymentProviderService_ + .withTransaction(transactionManager) + .updateSession(paymentSession, sessionInput) + } - const updatedCart = await this.retrieve(cartId) + if (!paymentSession.is_initiated) { + // Create the session remotely + paymentSession = await this.paymentProviderService_ + .withTransaction(transactionManager) + .createSession(sessionInput) + } + + await psRepo.update(paymentSession.id, { is_selected: true }) await this.eventBus_ .withTransaction(transactionManager) - .emit(CartService.Events.UPDATED, updatedCart) - return updatedCart + .emit(CartService.Events.UPDATED, { id: cartId }) }, "SERIALIZABLE" ) @@ -1709,6 +1731,9 @@ class CartService extends TransactionBaseService { this.paymentSessionRepository_ ) + const paymentProviderServiceTx = + this.paymentProviderService_.withTransaction(transactionManager) + const cartId = typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id @@ -1735,77 +1760,135 @@ class CartService extends TransactionBaseService { ) const { total, region } = cart + + // Helpers that either delete a session locally or remotely. Will be used in multiple places below. + const deleteSessionAppropriately = async (session) => { + if (session.is_selected || session.is_initiated) { + return paymentProviderServiceTx.deleteSession(session) + } + + return psRepo.delete(session) + } + + // In the case of a cart that has a total <= 0 we can return prematurely. + // we are deleting the sessions, and we don't need to create or update anything from now on. + if (total <= 0) { + await Promise.all( + cart.payment_sessions.map(async (session) => { + return deleteSessionAppropriately(session) + }) + ) + return + } + + const providerSet = new Set(region.payment_providers.map((p) => p.id)) + const alreadyConsumedProviderIds: Set = new Set() + const partialSessionInput: Omit = { cart: cart as Cart, customer: cart.customer, - amount: cart.total, + amount: total, currency_code: cart.region.currency_code, } - - // If there are existing payment sessions ensure that these are up to date - const seen: string[] = [] - if (cart.payment_sessions?.length) { - await Promise.all( - cart.payment_sessions.map(async (paymentSession) => { - if ( - total <= 0 || - !region.payment_providers.find( - ({ id }) => id === paymentSession.provider_id - ) - ) { - return this.paymentProviderService_ - .withTransaction(transactionManager) - .deleteSession(paymentSession) - } else { - seen.push(paymentSession.provider_id) - - const paymentSessionInput = { - ...partialSessionInput, - provider_id: paymentSession.provider_id, - } - - return this.paymentProviderService_ - .withTransaction(transactionManager) - .updateSession(paymentSession, paymentSessionInput) - } - }) - ) + const partialPaymentSessionData = { + cart_id: cartId, + data: {}, + status: PaymentSessionStatus.PENDING, + amount: total, } - if (total > 0) { - // If only one payment session exists, we preselect it - if (region.payment_providers.length === 1 && !cart.payment_session) { - const paymentProvider = region.payment_providers[0] - const paymentSessionInput = { - ...partialSessionInput, - provider_id: paymentProvider.id, + await Promise.all( + cart.payment_sessions.map(async (session) => { + if (!providerSet.has(session.provider_id)) { + /** + * if the provider does not belong to the region then delete the session. + * The deletion occurs locally if there is no external data or if it is not selected + * otherwise the deletion will also occur remotely through the external provider. + */ + + return await deleteSessionAppropriately(session) } - const paymentSession = await this.paymentProviderService_ - .withTransaction(transactionManager) - .createSession(paymentSessionInput) + /** + * if the provider belongs to the region then update or delete the session. + * The update occurs locally if it is not selected + * otherwise the update will also occur remotely through the external provider. + * In case the session is not selected but contains an external provider data, we delete the external provider + * session to be in a clean state. + */ - paymentSession.is_selected = true + // We are saving the provider id on which the work below will be done. That way, + // when handling the providers from the cart region at a later point below, we do not double the work on the sessions that already + // exists for the same provider. + alreadyConsumedProviderIds.add(session.provider_id) - await psRepo.save(paymentSession) - } else { - await Promise.all( - region.payment_providers.map(async (paymentProvider) => { - if (!seen.includes(paymentProvider.id)) { - const paymentSessionInput = { - ...partialSessionInput, - provider_id: paymentProvider.id, - } + // Update remotely + if (session.is_selected) { + const paymentSessionInput = { + ...partialSessionInput, + provider_id: session.provider_id, + } - return this.paymentProviderService_ - .withTransaction(transactionManager) - .createSession(paymentSessionInput) - } - return + return paymentProviderServiceTx.updateSession( + session, + paymentSessionInput + ) + } + + let updatedSession: PaymentSession + + // At this stage the session is not selected. Delete it remotely if there is some + // external provider data and create the session locally only. Otherwise, update the existing local session. + if (session.is_initiated) { + await paymentProviderServiceTx.deleteSession(session) + updatedSession = psRepo.create({ + ...partialPaymentSessionData, + provider_id: session.provider_id, }) - ) + } else { + updatedSession = { ...session, amount: total } as PaymentSession + } + + return psRepo.save(updatedSession) + }) + ) + + /** + * From now on, the sessions have been cleanup. We can now + * - Set the provider session as selected if it is the only one existing and there is no payment session on the cart + * - Create a session per provider locally if it does not already exists on the cart as per the previous step + */ + + // If only one provider exists and there is no session on the cart, create the session and select it. + if (region.payment_providers.length === 1 && !cart.payment_session) { + const paymentProvider = region.payment_providers[0] + + const paymentSessionInput = { + ...partialSessionInput, + provider_id: paymentProvider.id, } + + const paymentSession = await this.paymentProviderService_ + .withTransaction(transactionManager) + .createSession(paymentSessionInput) + + await psRepo.update(paymentSession.id, { is_selected: true }) + return } + + await Promise.all( + region.payment_providers.map(async (paymentProvider) => { + if (alreadyConsumedProviderIds.has(paymentProvider.id)) { + return + } + + const paymentSession = psRepo.create({ + ...partialPaymentSessionData, + provider_id: paymentProvider.id, + }) + return psRepo.save(paymentSession) + }) + ) } ) } @@ -1820,7 +1903,7 @@ class CartService extends TransactionBaseService { async deletePaymentSession( cartId: string, providerId: string - ): Promise { + ): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const cart = await this.retrieve(cartId, { @@ -1840,11 +1923,18 @@ class CartService extends TransactionBaseService { ({ provider_id }) => provider_id !== providerId ) + const psRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + if (paymentSession) { - // Delete the session with the provider - await this.paymentProviderService_ - .withTransaction(transactionManager) - .deleteSession(paymentSession) + if (paymentSession.is_selected || paymentSession.is_initiated) { + await this.paymentProviderService_ + .withTransaction(transactionManager) + .deleteSession(paymentSession) + } else { + await psRepo.delete(paymentSession) + } } } @@ -1852,8 +1942,7 @@ class CartService extends TransactionBaseService { await this.eventBus_ .withTransaction(transactionManager) - .emit(CartService.Events.UPDATED, cart) - return cart + .emit(CartService.Events.UPDATED, { id: cart.id }) } ) } @@ -1863,12 +1952,12 @@ class CartService extends TransactionBaseService { * @param cartId - the id of the cart to remove from * @param providerId - the id of the provider whoose payment session * should be removed. - * @return {Promise} the resulting cart. + * @return {Promise} the resulting cart. */ async refreshPaymentSession( cartId: string, providerId: string - ): Promise { + ): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const cart = await this.retrieveWithTotals(cartId, { @@ -1881,25 +1970,30 @@ class CartService extends TransactionBaseService { ) if (paymentSession) { - // Delete the session with the provider - await this.paymentProviderService_ - .withTransaction(transactionManager) - .refreshSession(paymentSession, { - cart: cart as Cart, - customer: cart.customer, + if (paymentSession.is_selected) { + await this.paymentProviderService_ + .withTransaction(transactionManager) + .refreshSession(paymentSession, { + cart: cart as Cart, + customer: cart.customer, + amount: cart.total, + currency_code: cart.region.currency_code, + provider_id: providerId, + }) + } else { + const psRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + await psRepo.update(paymentSession.id, { amount: cart.total, - currency_code: cart.region.currency_code, - provider_id: providerId, }) + } } } - const updatedCart = await this.retrieve(cartId) - await this.eventBus_ .withTransaction(transactionManager) - .emit(CartService.Events.UPDATED, updatedCart) - return updatedCart + .emit(CartService.Events.UPDATED, { id: cartId }) } ) } diff --git a/packages/medusa/src/services/payment-provider.ts b/packages/medusa/src/services/payment-provider.ts index a3844cbe0e..6f56593171 100644 --- a/packages/medusa/src/services/payment-provider.ts +++ b/packages/medusa/src/services/payment-provider.ts @@ -215,17 +215,15 @@ export default class PaymentProviderService extends TransactionBaseService { paymentResponse ) - const amount = this.featureFlagRouter_.isFeatureEnabled( - OrderEditingFeatureFlag.key - ) - ? context.amount - : undefined - return await this.saveSession(providerId, { + payment_session_id: !isString(providerIdOrSessionInput) + ? providerIdOrSessionInput.payment_session_id + : undefined, cartId: context.id, sessionData, status: PaymentSessionStatus.PENDING, - amount, + isInitiated: true, + amount: context.amount, }) }) } @@ -281,20 +279,24 @@ export default class PaymentProviderService extends TransactionBaseService { const context = this.buildPaymentProcessorContext(sessionInput) - const sessionData = await provider + const paymentResponse = await provider .withTransaction(transactionManager) .updatePayment(paymentSession.data, context) - const amount = this.featureFlagRouter_.isFeatureEnabled( - OrderEditingFeatureFlag.key + const sessionData = paymentResponse.session_data ?? paymentResponse + + await this.processUpdateRequestsData( + { + customer: { id: context.customer?.id }, + }, + paymentResponse ) - ? context.amount - : undefined return await this.saveSession(paymentSession.provider_id, { payment_session_id: paymentSession.id, sessionData, - amount, + isInitiated: true, + amount: context.amount, }) }) } @@ -687,44 +689,40 @@ export default class PaymentProviderService extends TransactionBaseService { amount?: number sessionData: Record isSelected?: boolean + isInitiated?: boolean status?: PaymentSessionStatus } ): Promise { const manager = this.transactionManager_ ?? this.manager_ - if ( - data.amount != null && - !this.featureFlagRouter_.isFeatureEnabled(OrderEditingFeatureFlag.key) - ) { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Amount on payment sessions is only available with the OrderEditing API currently guarded by feature flag `MEDUSA_FF_ORDER_EDITING`. Read more about feature flags here: https://docs.medusajs.com/advanced/backend/feature-flags/toggle/" - ) - } - const sessionRepo = manager.getCustomRepository( this.paymentSessionRepository_ ) + // Update an existing session if (data.payment_session_id) { const session = await this.retrieveSession(data.payment_session_id) session.data = data.sessionData ?? session.data session.status = data.status ?? session.status session.amount = data.amount ?? session.amount + session.is_initiated = data.isInitiated ?? session.is_initiated + session.is_selected = data.isSelected ?? session.is_selected return await sessionRepo.save(session) - } else { - const toCreate: Partial = { - cart_id: data.cartId || null, - provider_id: providerId, - data: data.sessionData, - is_selected: data.isSelected, - status: data.status, - amount: data.amount, - } - - const created = sessionRepo.create(toCreate) - return await sessionRepo.save(created) } + + // Create a new session + const toCreate: Partial = { + cart_id: data.cartId || null, + provider_id: providerId, + data: data.sessionData, + is_selected: data.isSelected, + is_initiated: data.isInitiated, + status: data.status, + amount: data.amount, + } + + const created = sessionRepo.create(toCreate) + return await sessionRepo.save(created) } /** diff --git a/packages/medusa/src/subscribers/cart.ts b/packages/medusa/src/subscribers/cart.ts index 36175add11..89f828f88b 100644 --- a/packages/medusa/src/subscribers/cart.ts +++ b/packages/medusa/src/subscribers/cart.ts @@ -1,28 +1,20 @@ import EventBusService from "../services/event-bus" -import { CartService, PaymentProviderService } from "../services" +import { CartService } from "../services" import { EntityManager } from "typeorm" type InjectedDependencies = { eventBusService: EventBusService cartService: CartService - paymentProviderService: PaymentProviderService manager: EntityManager } class CartSubscriber { protected readonly manager_: EntityManager protected readonly cartService_: CartService - protected readonly paymentProviderService_: PaymentProviderService protected readonly eventBus_: EventBusService - constructor({ - manager, - cartService, - paymentProviderService, - eventBusService, - }: InjectedDependencies) { + constructor({ manager, cartService, eventBusService }: InjectedDependencies) { this.cartService_ = cartService - this.paymentProviderService_ = paymentProviderService this.eventBus_ = eventBusService this.manager_ = manager @@ -38,30 +30,18 @@ class CartSubscriber { await this.manager_.transaction( "SERIALIZABLE", async (transactionManager) => { - const cart = await this.cartService_ - .withTransaction(transactionManager) - .retrieveWithTotals(cartId, { - relations: [ - "billing_address", - "region", - "region.payment_providers", - "payment_sessions", - "customer", - ], - }) + const cartServiceTx = + this.cartService_.withTransaction(transactionManager) + + const cart = await cartServiceTx.retrieve(cartId, { + relations: ["payment_sessions"], + }) if (!cart.payment_sessions?.length) { return } - const paymentProviderServiceTx = - this.paymentProviderService_.withTransaction(transactionManager) - - return await Promise.all( - cart.payment_sessions.map(async (paymentSession) => { - return paymentProviderServiceTx.updateSession(paymentSession, cart) - }) - ) + return await cartServiceTx.setPaymentSessions(cart.id) } ) } diff --git a/packages/medusa/src/types/payment.ts b/packages/medusa/src/types/payment.ts index b379672aab..6cbb970bd1 100644 --- a/packages/medusa/src/types/payment.ts +++ b/packages/medusa/src/types/payment.ts @@ -7,6 +7,7 @@ import { } from "../models" export type PaymentSessionInput = { + payment_session_id?: string provider_id: string // TODO: Support legacy payment provider API> Once we are ready to break the api then we can remove the Cart type cart: