diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImpl.java index 9809f6342..4543539d1 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImpl.java @@ -22,9 +22,14 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Stack; +import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.domain.FineractContext; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.event.business.BusinessEventListener; import org.apache.fineract.infrastructure.event.business.domain.BulkBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; @@ -33,12 +38,16 @@ import org.apache.fineract.infrastructure.event.external.repository.ExternalEven import org.apache.fineract.infrastructure.event.external.service.ExternalEventService; import org.springframework.beans.factory.InitializingBean; import org.springframework.stereotype.Service; +import org.springframework.transaction.TransactionExecution; +import org.springframework.transaction.TransactionExecutionListener; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; @Service @SuppressWarnings({ "unchecked", "rawtypes" }) @RequiredArgsConstructor @Slf4j -public class BusinessEventNotifierServiceImpl implements BusinessEventNotifierService, InitializingBean { +public class BusinessEventNotifierServiceImpl implements BusinessEventNotifierService, InitializingBean, TransactionExecutionListener { private final Map> preListeners = new HashMap<>(); private final Map> postListeners = new HashMap<>(); @@ -49,6 +58,8 @@ public class BusinessEventNotifierServiceImpl implements BusinessEventNotifierSe private final ExternalEventService externalEventService; private final ExternalEventConfigurationRepository eventConfigurationRepository; private final FineractProperties fineractProperties; + private final ThreadLocal>> transactionBusinessEvents = ThreadLocal.withInitial(Stack::new); + private final TransactionHelper transactionHelper; @Override public void afterPropertiesSet() throws Exception { @@ -79,6 +90,7 @@ public class BusinessEventNotifierServiceImpl implements BusinessEventNotifierSe } @Override + @Transactional(propagation = Propagation.SUPPORTS) public void notifyPostBusinessEvent(BusinessEvent businessEvent) { throwExceptionIfBulkEvent(businessEvent); boolean isExternalEvent = !(businessEvent instanceof NoExternalEvent); @@ -92,7 +104,11 @@ public class BusinessEventNotifierServiceImpl implements BusinessEventNotifierSe if (isExternalEventRecordingEnabled()) { recordedEvents.get().add(businessEvent); } else { - externalEventService.postEvent(businessEvent); + if (transactionHelper.hasTransaction()) { + storeTransactionalBusinessEvent(businessEvent); + } else { + externalEventService.postEvent(businessEvent); + } } } } @@ -170,4 +186,58 @@ public class BusinessEventNotifierServiceImpl implements BusinessEventNotifierSe eventRecordingEnabled.set(false); recordedEvents.remove(); } + + private void storeTransactionalBusinessEvent(BusinessEvent businessEvent) { + List businessEvents = transactionBusinessEvents.get().peek(); + FineractContext fineractContext = ThreadLocalContextUtil.getContext(); + businessEvents.add(new BusinessEventWithContext(businessEvent, fineractContext)); + } + + private void cleanup() { + transactionBusinessEvents.get().pop(); + } + + @Override + public void afterBegin(TransactionExecution transaction, Throwable beginFailure) { + transactionBusinessEvents.get().push(new ArrayList<>()); + } + + @Override + public void beforeCommit(TransactionExecution transaction) { + List businessEventWithContexts = transactionBusinessEvents.get().peek(); + if (!businessEventWithContexts.isEmpty()) { + FineractContext originalContext = ThreadLocalContextUtil.getContext(); + try { + for (BusinessEventWithContext businessEventWithContext : businessEventWithContexts) { + ThreadLocalContextUtil.init(businessEventWithContext.getFineractContext()); + externalEventService.postEvent(businessEventWithContext.getEvent()); + } + } finally { + ThreadLocalContextUtil.init(originalContext); + } + } + } + + @Override + public void afterCommit(TransactionExecution transaction, Throwable commitFailure) { + cleanup(); + } + + @Override + public void afterRollback(TransactionExecution transaction, Throwable rollbackFailure) { + cleanup(); + } + + @Getter + @Setter + private static final class BusinessEventWithContext { + + private BusinessEvent event; + private FineractContext fineractContext; + + BusinessEventWithContext(BusinessEvent event, FineractContext fineractContext) { + this.event = event; + this.fineractContext = fineractContext; + } + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/business/service/TransactionHelper.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/business/service/TransactionHelper.java new file mode 100644 index 000000000..7a30304e0 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/business/service/TransactionHelper.java @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.service; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; + +@Component +@Transactional(propagation = Propagation.SUPPORTS) +public class TransactionHelper { + + boolean hasTransaction() { + return TransactionAspectSupport.currentTransactionStatus().hasTransaction(); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImplTest.java index f69be4296..73d0ee2b9 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImplTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/business/service/BusinessEventNotifierServiceImplTest.java @@ -27,7 +27,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.time.LocalDate; +import java.util.HashMap; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.event.business.BusinessEventListener; import org.apache.fineract.infrastructure.event.business.domain.BulkBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; @@ -43,6 +47,7 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.transaction.TransactionExecution; @ExtendWith(MockitoExtension.class) @SuppressWarnings({ "rawtypes", "unchecked" }) @@ -58,9 +63,140 @@ class BusinessEventNotifierServiceImplTest { @Mock private FineractProperties fineractProperties; + @Mock + private TransactionHelper transactionHelper; + @InjectMocks private BusinessEventNotifierServiceImpl underTest; + @Test + public void testNotifyPostBusinessEventShouldCollectEventsWithinTransaction() { + // given + setBusinessDate(); + configureExternalEventsProperties(true); + MockBusinessEvent event = new MockBusinessEvent(); + BusinessEventListener postListener = mockListener(); + underTest.addPostBusinessEventListener(MockBusinessEvent.class, postListener); + TransactionExecution mockTransaction = mock(TransactionExecution.class); + underTest.afterBegin(mockTransaction, null); + // when + when(externalEventConfigurationRepository.findExternalEventConfigurationByTypeWithNotFoundDetection(Mockito.any())) + .thenReturn(new ExternalEventConfiguration("aType", true)); + when(transactionHelper.hasTransaction()).thenReturn(true); + underTest.notifyPostBusinessEvent(event); + // then + verify(postListener).onBusinessEvent(event); + verifyNoInteractions(externalEventService); + // simulate finish transaction + underTest.beforeCommit(mockTransaction); + verify(externalEventService).postEvent(event); + underTest.afterCommit(mockTransaction, null); + verifyNoInteractions(mockTransaction); + } + + private void setBusinessDate() { + HashMap map = new HashMap<>(2); + map.put(BusinessDateType.BUSINESS_DATE, LocalDate.of(2023, 2, 1)); + map.put(BusinessDateType.COB_DATE, LocalDate.of(2023, 1, 31)); + ThreadLocalContextUtil.setBusinessDates(map); + } + + @Test + public void testNotifyPostBusinessEventShouldCollectEventsWithinTransactionInNestedTransaction() { + // given + setBusinessDate(); + configureExternalEventsProperties(true); + MockBusinessEvent event = new MockBusinessEvent(); + MockBusinessEvent nestedEvent = new MockBusinessEvent(); + BusinessEventListener postListener = mockListener(); + underTest.addPostBusinessEventListener(MockBusinessEvent.class, postListener); + TransactionExecution mockTransaction = mock(TransactionExecution.class); + // when + when(externalEventConfigurationRepository.findExternalEventConfigurationByTypeWithNotFoundDetection(Mockito.any())) + .thenReturn(new ExternalEventConfiguration("aType", true)); + when(transactionHelper.hasTransaction()).thenReturn(true); + + // simulate outer transaction + underTest.afterBegin(mockTransaction, null); + underTest.notifyPostBusinessEvent(event); + verify(postListener).onBusinessEvent(event); + verifyNoInteractions(externalEventService); + // simulate nested transaction + underTest.afterBegin(mockTransaction, null); + underTest.notifyPostBusinessEvent(nestedEvent); + verify(postListener).onBusinessEvent(nestedEvent); + verifyNoInteractions(externalEventService); + // simulate commit nested transaction + underTest.beforeCommit(mockTransaction); + underTest.afterCommit(mockTransaction, null); + verify(externalEventService).postEvent(nestedEvent); + // simulate commit outer transaction + underTest.beforeCommit(mockTransaction); + verify(externalEventService).postEvent(event); + underTest.afterCommit(mockTransaction, null); + verifyNoInteractions(mockTransaction); + } + + @Test + public void testNotifyPostBusinessEventShouldCollectEventsWithinTransactionInNestedRollbackTransaction() { + // given + setBusinessDate(); + configureExternalEventsProperties(true); + MockBusinessEvent event = new MockBusinessEvent(); + MockBusinessEvent nestedEvent = new MockBusinessEvent(); + BusinessEventListener postListener = mockListener(); + underTest.addPostBusinessEventListener(MockBusinessEvent.class, postListener); + TransactionExecution mockTransaction = mock(TransactionExecution.class); + // when + when(externalEventConfigurationRepository.findExternalEventConfigurationByTypeWithNotFoundDetection(Mockito.any())) + .thenReturn(new ExternalEventConfiguration("aType", true)); + when(transactionHelper.hasTransaction()).thenReturn(true); + + // simulate outer transaction + underTest.afterBegin(mockTransaction, null); + underTest.notifyPostBusinessEvent(event); + verify(postListener).onBusinessEvent(event); + verifyNoInteractions(externalEventService); + // simulate nested transaction + underTest.afterBegin(mockTransaction, null); + underTest.notifyPostBusinessEvent(nestedEvent); + verify(postListener).onBusinessEvent(nestedEvent); + verifyNoInteractions(externalEventService); + // simulate commit nested transaction + underTest.afterRollback(mockTransaction, null); + verifyNoInteractions(externalEventService); + // simulate commit outer transaction + underTest.beforeCommit(mockTransaction); + verify(externalEventService).postEvent(event); + underTest.afterCommit(mockTransaction, null); + verifyNoInteractions(mockTransaction); + } + + @Test + public void testNotifyPostBusinessEventShouldCollectEventsWithinTransactionAndNotSendExternalOnRollback() { + // given + setBusinessDate(); + configureExternalEventsProperties(true); + MockBusinessEvent event = new MockBusinessEvent(); + BusinessEventListener postListener = mockListener(); + underTest.addPostBusinessEventListener(MockBusinessEvent.class, postListener); + TransactionExecution mockTransaction = mock(TransactionExecution.class); + underTest.afterBegin(mockTransaction, null); + // when + when(externalEventConfigurationRepository.findExternalEventConfigurationByTypeWithNotFoundDetection(Mockito.any())) + .thenReturn(new ExternalEventConfiguration("aType", true)); + when(transactionHelper.hasTransaction()).thenReturn(true); + underTest.notifyPostBusinessEvent(event); + // then + verify(postListener).onBusinessEvent(event); + verifyNoInteractions(externalEventService); + // simulate rollback transaction + verifyNoInteractions(externalEventService); + underTest.afterRollback(mockTransaction, null); + verifyNoInteractions(externalEventService); + verifyNoInteractions(mockTransaction); + } + @Test public void testNotifyPostBusinessEventShouldNotifyPostListeners() { // given @@ -85,6 +221,7 @@ class BusinessEventNotifierServiceImplTest { BusinessEventListener postListener = mockListener(); underTest.addPostBusinessEventListener(MockBusinessEvent.class, postListener); + when(transactionHelper.hasTransaction()).thenReturn(false); when(externalEventConfigurationRepository.findExternalEventConfigurationByTypeWithNotFoundDetection(Mockito.any())) .thenReturn(new ExternalEventConfiguration("aType", true)); // when diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java index 98f9ab0b1..abb535e37 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java @@ -46,8 +46,7 @@ import org.junit.jupiter.api.extension.ExtendWith; @ExtendWith({ LoanTestLifecycleExtension.class, ExternalEventsExtension.class }) public class CustomSnapshotEventIntegrationTest extends BaseLoanIntegrationTest { - private SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); - private Gson gson = new Gson(); + private final SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); @Test public void testSnapshotEventGenerationWhenLoanInstallmentIsNotPayed() { @@ -221,9 +220,8 @@ public class CustomSnapshotEventIntegrationTest extends BaseLoanIntegrationTest public void testNoSnapshotEventGenerationWhenCOBDateIsNotMatchingWithInstallmentDueDate() { runAt("30 January 2023", () -> { // Enable Business Step - enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE", - "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", - "EXTERNAL_ASSET_OWNER_TRANSFER", "CHECK_DUE_INSTALLMENTS"); + enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_OVERDUE", + "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", "EXTERNAL_ASSET_OWNER_TRANSFER", "CHECK_DUE_INSTALLMENTS"); enableLoanAccountCustomSnapshotBusinessEvent(); @@ -265,6 +263,8 @@ public class CustomSnapshotEventIntegrationTest extends BaseLoanIntegrationTest @Test public void testNoSnapshotEventGenerationWhenCustomSnapshotEventIsDisabled() { runAt("31 January 2023", () -> { + // disable custom snapshot event + disableLoanAccountCustomSnapshotBusinessEvent(); // Enable Business Step enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE", "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", @@ -333,6 +333,14 @@ public class CustomSnapshotEventIntegrationTest extends BaseLoanIntegrationTest Assertions.assertTrue(updatedConfigurations.get("LoanAccountCustomSnapshotBusinessEvent")); } + private void disableLoanAccountCustomSnapshotBusinessEvent() { + final Map updatedConfigurations = ExternalEventConfigurationHelper.updateExternalEventConfigurations(requestSpec, + responseSpec, "{\"externalEventConfigurations\":{\"LoanAccountCustomSnapshotBusinessEvent\":false}}\n"); + Assertions.assertEquals(updatedConfigurations.size(), 1); + Assertions.assertTrue(updatedConfigurations.containsKey("LoanAccountCustomSnapshotBusinessEvent")); + Assertions.assertFalse(updatedConfigurations.get("LoanAccountCustomSnapshotBusinessEvent")); + } + private void updateBusinessDateAndExecuteCOBJob(String date) { businessDateHelper.updateBusinessDate( new BusinessDateRequest().type(BUSINESS_DATE.getName()).date(date).dateFormat(DATETIME_PATTERN).locale("en"));