diff --git a/.github/workflows/verify-liquibase-backward-compatibility.yml b/.github/workflows/verify-liquibase-backward-compatibility.yml index 56d7e25824e..744bf27e074 100644 --- a/.github/workflows/verify-liquibase-backward-compatibility.yml +++ b/.github/workflows/verify-liquibase-backward-compatibility.yml @@ -31,12 +31,20 @@ jobs: TZ: Asia/Kolkata steps: - - name: Checkout the base branch (`develop`) + - name: Checkout base commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ github.event.pull_request.base.repo.full_name }} - ref: ${{ github.event.pull_request.base.ref }} + ref: ${{ github.event.pull_request.base.sha }} fetch-depth: 0 + path: baseline + + - name: Checkout PR merge commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + path: current - name: Set up JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 @@ -52,11 +60,29 @@ jobs: done - name: Init base schema + working-directory: baseline run: | ./gradlew --no-daemon createPGDB -PdbName=fineract_tenants ./gradlew --no-daemon createPGDB -PdbName=$DB_NAME - - name: Start backend on base branch + - name: Print checked out revisions + run: | + echo "Base branch ref: ${{ github.event.pull_request.base.ref }}" + echo "Base branch event SHA:" + echo "${{ github.event.pull_request.base.sha }}" + echo "PR head SHA:" + echo "${{ github.event.pull_request.head.sha }}" + echo "GitHub merge SHA:" + echo "${{ github.sha }}" + echo "Baseline revision:" + git -C baseline rev-parse HEAD + git -C baseline log -1 --oneline + echo "Merged PR revision:" + git -C current rev-parse HEAD + git -C current log -1 --oneline + + - name: Start backend on base commit + working-directory: baseline run: | ./gradlew :fineract-provider:devRun --args="\ --spring.datasource.hikari.driverClassName=org.postgresql.Driver \ @@ -79,19 +105,16 @@ jobs: start_ts=$(date +%s) while true; do - # If the process died, fail fast if ! kill -0 "$BACKEND_PID" 2>/dev/null; then echo "Backend process exited before Actuator became available." exit 1 fi - # Check endpoint if curl -kfsS "$ACTUATOR_URL" >/dev/null 2>&1; then echo "Actuator is up." break fi - # Timeout now_ts=$(date +%s) if [ $((now_ts - start_ts)) -ge "$TIMEOUT_SECONDS" ]; then echo "Timed out waiting for Actuator." @@ -101,19 +124,15 @@ jobs: sleep "$INTERVAL_SECONDS" done - - name: Stop backend + - name: Stop baseline backend + if: always() + working-directory: baseline run: | kill $(cat backend.pid) sleep 10 - - name: Checkout the PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - - name: Start backend on PR branch + - name: Start backend on merged PR commit + working-directory: current run: | ./gradlew :fineract-provider:devRun --args="\ --spring.datasource.hikari.driverClassName=org.postgresql.Driver \ @@ -136,19 +155,16 @@ jobs: start_ts=$(date +%s) while true; do - # If the process died, fail fast if ! kill -0 "$BACKEND_PID" 2>/dev/null; then echo "Backend process exited before Actuator became available." exit 1 fi - # Check endpoint if curl -kfsS "$ACTUATOR_URL" >/dev/null 2>&1; then echo "Actuator is up." break fi - # Timeout now_ts=$(date +%s) if [ $((now_ts - start_ts)) -ge "$TIMEOUT_SECONDS" ]; then echo "Timed out waiting for Actuator." @@ -157,7 +173,10 @@ jobs: sleep "$INTERVAL_SECONDS" done - - name: Stop backend + + - name: Stop PR backend + if: always() + working-directory: current run: | kill $(cat backend.pid) sleep 10 diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationService.java index 1e384d59cf7..4ec19545d10 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationService.java @@ -78,10 +78,6 @@ private void validateEventConfigurationForIndividualTenant(FineractPlatformTenan log.debug("Missing from eventConfigurations: {}", CollectionUtils.subtract(eventConfigurations, eventClasses)); } - if (eventClasses.size() != eventConfigurations.size()) { - throw new ExternalEventConfigurationNotFoundException(); - } - for (String eventTypeClass : eventClasses) { if (!eventConfigurations.contains(eventTypeClass)) { throw new ExternalEventConfigurationNotFoundException(eventTypeClass); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOriginationStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOriginationStepDef.java index 9bab16f1573..2c6940d1db5 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOriginationStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOriginationStepDef.java @@ -27,11 +27,14 @@ import io.cucumber.java.en.Then; import io.cucumber.java.en.When; import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.avro.loan.v1.LoanTransactionAdjustmentDataV1; +import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1; import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; @@ -41,11 +44,14 @@ import org.apache.fineract.client.models.GetLoanOriginatorsResponse; import org.apache.fineract.client.models.GetLoansLoanIdOriginatorData; import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdTransactions; import org.apache.fineract.client.models.PostClientsResponse; import org.apache.fineract.client.models.PostLoanOriginatorsRequest; import org.apache.fineract.client.models.PostLoanOriginatorsResponse; import org.apache.fineract.client.models.PostLoansLoanIdRequest; import org.apache.fineract.client.models.PostLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest; import org.apache.fineract.client.models.PostLoansOriginatorData; import org.apache.fineract.client.models.PostLoansRequest; import org.apache.fineract.client.models.PostLoansResponse; @@ -56,6 +62,8 @@ import org.apache.fineract.test.helper.ErrorMessageHelper; import org.apache.fineract.test.messaging.EventAssertion; import org.apache.fineract.test.messaging.event.loan.LoanApprovedEvent; +import org.apache.fineract.test.messaging.event.loan.transaction.LoanAccrualTransactionCreatedBusinessEvent; +import org.apache.fineract.test.messaging.event.loan.transaction.LoanAdjustTransactionBusinessEvent; import org.apache.fineract.test.messaging.store.EventStore; import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; @@ -65,6 +73,10 @@ public class LoanOriginationStepDef extends AbstractStepDef { private static final long NON_EXISTENT_ID = Long.MAX_VALUE; + private static final String DATE_FORMAT = "dd MMMM yyyy"; + private static final String DEFAULT_LOCALE = "en"; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT); + private static final String ADJUSTED_TRANSACTION_ID = "adjustedTransactionId"; @Autowired private FineractFeignClient fineractClient; @@ -603,6 +615,135 @@ public void deleteOriginatorShouldFailWithStatus(int expectedStatus) { log.info("Deleting originator {} failed with expected status {}", originatorId, expectedStatus); } + @When("Customer makes a repayment undo on {string} without event check") + public void makeLoanRepaymentUndoWithoutEventCheck(String transactionDate) { + eventStore.reset(); + PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + PostLoansLoanIdTransactionsResponse repaymentResponse = testContext().get(TestContextKey.LOAN_REPAYMENT_RESPONSE); + Long originalTransactionId = repaymentResponse.getResourceId(); + + PostLoansLoanIdTransactionsTransactionIdRequest repaymentUndoRequest = LoanRequestFactory.defaultRepaymentUndoRequest() + .transactionDate(transactionDate).dateFormat(DATE_FORMAT).locale(DEFAULT_LOCALE); + + ok(() -> fineractClient.loanTransactions().adjustLoanTransaction(loanId, originalTransactionId, repaymentUndoRequest, + Map.of())); + testContext().set(ADJUSTED_TRANSACTION_ID, originalTransactionId); + log.info("Repayment {} undo on loan {} (event check skipped for separate originator verification)", originalTransactionId, loanId); + } + + @When("Customer adjusts the repayment on {string} to {double} EUR without event check") + public void adjustLoanRepaymentWithoutEventCheck(String transactionDate, double transactionAmount) { + eventStore.reset(); + PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + PostLoansLoanIdTransactionsResponse repaymentResponse = testContext().get(TestContextKey.LOAN_REPAYMENT_RESPONSE); + Long originalTransactionId = repaymentResponse.getResourceId(); + + PostLoansLoanIdTransactionsTransactionIdRequest repaymentAdjustRequest = LoanRequestFactory + .defaultRepaymentAdjustRequest(transactionAmount).transactionDate(transactionDate).dateFormat(DATE_FORMAT) + .locale(DEFAULT_LOCALE); + + PostLoansLoanIdTransactionsResponse repaymentAdjustmentResponse = ok(() -> fineractClient.loanTransactions() + .adjustLoanTransaction(loanId, originalTransactionId, repaymentAdjustRequest, Map.of())); + testContext().set(TestContextKey.LOAN_REPAYMENT_UNDO_RESPONSE, repaymentAdjustmentResponse); + testContext().set(ADJUSTED_TRANSACTION_ID, originalTransactionId); + log.info("Repayment {} adjusted to {} on loan {} (event check skipped for separate originator verification)", originalTransactionId, + transactionAmount, loanId); + } + + @When("Customer reverses the waiver transaction on {string}") + public void reverseWaiverTransaction(String transactionDate) { + eventStore.reset(); + PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + + GetLoansLoanIdResponse loanDetails = ok( + () -> fineractClient.loans().retrieveLoan(loanId, Map.of("associations", "transactions"))); + Long waiveTransactionId = loanDetails.getTransactions().stream() + .filter(t -> "loanTransactionType.waiveCharges".equals(t.getType().getCode())).map(GetLoansLoanIdTransactions::getId) + .findFirst().orElseThrow(() -> new IllegalStateException("Waiver transaction not found on loan " + loanId)); + + PostLoansLoanIdTransactionsTransactionIdRequest undoRequest = LoanRequestFactory.defaultRepaymentUndoRequest() + .transactionDate(transactionDate).dateFormat(DATE_FORMAT).locale(DEFAULT_LOCALE); + + ok(() -> fineractClient.loanTransactions().adjustLoanTransaction(loanId, waiveTransactionId, undoRequest, + Map.of())); + testContext().set(ADJUSTED_TRANSACTION_ID, waiveTransactionId); + log.info("Waiver transaction {} reversed on loan {} (for originator event verification)", waiveTransactionId, loanId); + } + + // --- Originator event verification steps --- + + @Then("LoanAdjustTransactionBusinessEvent is created with originator details in {string}") + public void verifyOriginatorInAdjustEvent(String nestedField) { + long loanId = getLoanId(); + Long adjustedTransactionId = testContext().get(ADJUSTED_TRANSACTION_ID); + String expectedExternalId = testContext().get(TestContextKey.ORIGINATOR_EXTERNAL_ID); + + eventAssertion.assertEvent(LoanAdjustTransactionBusinessEvent.class, adjustedTransactionId).extractingData(adjustmentData -> { + LoanTransactionDataV1 nested = resolveAdjustmentField(adjustmentData, nestedField); + assertThat(nested).as("Field '%s' in LoanAdjustTransactionBusinessEvent", nestedField).isNotNull(); + + List originators = nested.getOriginators(); + assertThat(originators).as("Originators in %s should not be null or empty", nestedField).isNotNull().isNotEmpty(); + assertThat(originators.get(0).getExternalId()).as("Originator externalId in %s", nestedField).isEqualTo(expectedExternalId); + assertThat(originators.get(0).getStatus()).as("Originator status in %s", nestedField).isEqualTo("ACTIVE"); + return adjustmentData.getTransactionToAdjust().getId(); + }).isEqualTo(adjustedTransactionId); + log.info("Verified originator {} in LoanAdjustTransactionBusinessEvent.{} for loan {}", expectedExternalId, nestedField, loanId); + } + + @Then("LoanAdjustTransactionBusinessEvent is created without originator details in {string}") + public void verifyNoOriginatorInAdjustEvent(String nestedField) { + Long adjustedTransactionId = testContext().get(ADJUSTED_TRANSACTION_ID); + + eventAssertion.assertEvent(LoanAdjustTransactionBusinessEvent.class, adjustedTransactionId).extractingData(adjustmentData -> { + LoanTransactionDataV1 nested = resolveAdjustmentField(adjustmentData, nestedField); + assertThat(nested).as("Field '%s' in LoanAdjustTransactionBusinessEvent", nestedField).isNotNull(); + + List originators = nested.getOriginators(); + assertThat(originators).as("Originators in %s should be null or empty", nestedField).isNullOrEmpty(); + return adjustmentData.getTransactionToAdjust().getId(); + }).isEqualTo(adjustedTransactionId); + log.info("Verified no originators in LoanAdjustTransactionBusinessEvent.{}", nestedField); + } + + @Then("LoanAccrualTransactionCreatedBusinessEvent is created with originator details on {string}") + public void verifyOriginatorInAccrualEvent(String date) { + long loanId = getLoanId(); + String expectedExternalId = testContext().get(TestContextKey.ORIGINATOR_EXTERNAL_ID); + + GetLoansLoanIdResponse loanDetails = ok(() -> fineractClient.loans().retrieveLoan(loanId, + Map.of("staffInSelectedOfficeOnly", "false", "associations", "transactions"))); + GetLoansLoanIdTransactions accrualTransaction = loanDetails.getTransactions().stream() + .filter(t -> date.equals(FORMATTER.format(t.getDate())) && "Accrual".equals(t.getType().getValue())) + .reduce((first, second) -> second) + .orElseThrow(() -> new IllegalStateException(String.format("No Accrual transaction found on %s", date))); + + eventAssertion.assertEvent(LoanAccrualTransactionCreatedBusinessEvent.class, accrualTransaction.getId()) + .extractingData(loanTransactionDataV1 -> { + List originators = loanTransactionDataV1.getOriginators(); + assertThat(originators).as("Originators in LoanAccrualTransactionCreatedBusinessEvent should not be null or empty") + .isNotNull().isNotEmpty(); + assertThat(originators.get(0).getExternalId()).as("Originator externalId in LoanAccrualTransactionCreatedBusinessEvent") + .isEqualTo(expectedExternalId); + assertThat(originators.get(0).getStatus()).as("Originator status in LoanAccrualTransactionCreatedBusinessEvent") + .isEqualTo("ACTIVE"); + return loanTransactionDataV1.getId(); + }).isEqualTo(accrualTransaction.getId()); + log.info("Verified originator {} in LoanAccrualTransactionCreatedBusinessEvent on {} for loan {}", expectedExternalId, date, + loanId); + } + + private LoanTransactionDataV1 resolveAdjustmentField(LoanTransactionAdjustmentDataV1 data, String field) { + return switch (field) { + case "transactionToAdjust" -> data.getTransactionToAdjust(); + case "newTransactionDetail" -> data.getNewTransactionDetail(); + default -> throw new IllegalArgumentException("Unknown adjustment field: " + field); + }; + } + // --- Helper methods --- private long getLoanId() { diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanOrigination.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanOrigination.feature index 9355329d759..1e54fe7998f 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanOrigination.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanOrigination.feature @@ -269,3 +269,68 @@ Feature: Loan Origination When Admin creates new user with "ORIGINATOR_NO_DELETE" username, "ORIGINATOR_NO_DELETE_ROLE" role name and given permissions: | READ_LOAN_ORIGINATOR | Then Created user without DELETE_LOAN_ORIGINATOR permission fails to delete the originator + + @TestRailId:C74521 + Scenario: Verify that originator details are present in LoanAdjustTransactionBusinessEvent after repayment reversal + When Admin sets the business date to "1 January 2025" + When Admin creates a client with random data + When Admin creates a new loan originator with external ID and name "Adjust Event Originator" + When Admin creates a new default Loan with date: "1 January 2025" + When Admin attaches the originator to the loan + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + When Admin successfully disburse the loan on "1 January 2025" with "1000" EUR transaction amount + And Customer makes "AUTOPAY" repayment on "1 January 2025" with 500 EUR transaction amount + When Customer makes a repayment undo on "1 January 2025" without event check + Then LoanAdjustTransactionBusinessEvent is created with originator details in "transactionToAdjust" + + @TestRailId:C74522 + Scenario: Verify that originator details are present in LoanAccrualTransactionCreatedBusinessEvent after COB runs + When Admin sets the business date to "1 January 2025" + When Admin creates a client with random data + When Admin creates a new loan originator with external ID and name "Accrual Event Originator" + When Admin creates a new default Loan with date: "1 January 2025" + When Admin attaches the originator to the loan + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + When Admin successfully disburse the loan on "1 January 2025" with "1000" EUR transaction amount + When Admin adds "LOAN_SNOOZE_FEE" due date charge with "1 January 2025" due date and 10 EUR transaction amount + When Admin sets the business date to "2 January 2025" + When Admin runs inline COB job for Loan + Then LoanAccrualTransactionCreatedBusinessEvent is created with originator details on "01 January 2025" + + @TestRailId:C74523 + Scenario: Verify that originator details are present in LoanAdjustTransactionBusinessEvent after charge waiver reversal + When Admin sets the business date to "1 January 2025" + When Admin creates a client with random data + When Admin creates a new loan originator with external ID and name "Waiver Reversal Originator" + When Admin creates a new default Loan with date: "1 January 2025" + When Admin attaches the originator to the loan + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + When Admin successfully disburse the loan on "1 January 2025" with "1000" EUR transaction amount + When Admin adds "LOAN_SNOOZE_FEE" due date charge with "1 January 2025" due date and 10 EUR transaction amount + And Admin waives due date charge + When Customer reverses the waiver transaction on "1 January 2025" + Then LoanAdjustTransactionBusinessEvent is created with originator details in "transactionToAdjust" + + @TestRailId:C74524 + Scenario: Verify that no originator details are present in LoanAdjustTransactionBusinessEvent when loan has no originator attached + When Admin sets the business date to "1 January 2025" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "1 January 2025" + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + When Admin successfully disburse the loan on "1 January 2025" with "1000" EUR transaction amount + And Customer makes "AUTOPAY" repayment on "1 January 2025" with 500 EUR transaction amount + When Customer makes a repayment undo on "1 January 2025" without event check + Then LoanAdjustTransactionBusinessEvent is created without originator details in "transactionToAdjust" + + @TestRailId:C74538 + Scenario: Verify that originator details are present in LoanAdjustTransactionBusinessEvent new transaction detail after repayment adjustment + When Admin sets the business date to "1 January 2025" + When Admin creates a client with random data + When Admin creates a new loan originator with external ID and name "Adjustment Replacement Originator" + When Admin creates a new default Loan with date: "1 January 2025" + When Admin attaches the originator to the loan + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + When Admin successfully disburse the loan on "1 January 2025" with "1000" EUR transaction amount + And Customer makes "AUTOPAY" repayment on "1 January 2025" with 500 EUR transaction amount + When Customer adjusts the repayment on "1 January 2025" to 300 EUR without event check + Then LoanAdjustTransactionBusinessEvent is created with originator details in "newTransactionDetail" diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricher.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricher.java index 85d3059fffb..d0fcada8d03 100644 --- a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricher.java +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricher.java @@ -18,15 +18,12 @@ */ package org.apache.fineract.portfolio.loanorigination.enricher; -import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.avro.loan.v1.LoanAccountDataV1; import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; import org.apache.fineract.infrastructure.core.service.DataEnricher; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -35,8 +32,7 @@ @ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") public class LoanAccountDataV1OriginatorEnricher implements DataEnricher { - private final LoanOriginatorMappingRepository loanOriginatorMappingRepository; - private final LoanOriginatorAvroMapper loanOriginatorAvroMapper; + private final LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; @Override public boolean isDataTypeSupported(final Class dataType) { @@ -49,22 +45,7 @@ public void enrich(final LoanAccountDataV1 data) { return; } - final List mappings = loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(data.getId()); - if (mappings == null || mappings.isEmpty()) { - return; - } - - final List originators = new ArrayList<>(); - for (LoanOriginatorMapping mapping : mappings) { - final LoanOriginator originator = mapping.getOriginator(); - if (originator != null) { - final OriginatorDetailsV1 originatorDetails = loanOriginatorAvroMapper.toAvro(originator); - if (originatorDetails != null) { - originators.add(originatorDetails); - } - } - } - + final List originators = loanOriginatorDetailsResolver.resolveOriginatorDetails(data.getId()); if (!originators.isEmpty()) { data.setOriginators(originators); } diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanChargeDataV1OriginatorEnricher.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanChargeDataV1OriginatorEnricher.java index df59ee046d5..6a922c0e0f7 100644 --- a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanChargeDataV1OriginatorEnricher.java +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanChargeDataV1OriginatorEnricher.java @@ -18,15 +18,12 @@ */ package org.apache.fineract.portfolio.loanorigination.enricher; -import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.avro.loan.v1.LoanChargeDataV1; import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; import org.apache.fineract.infrastructure.core.service.DataEnricher; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -35,8 +32,7 @@ @ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") public class LoanChargeDataV1OriginatorEnricher implements DataEnricher { - private final LoanOriginatorMappingRepository loanOriginatorMappingRepository; - private final LoanOriginatorAvroMapper loanOriginatorAvroMapper; + private final LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; @Override public boolean isDataTypeSupported(final Class dataType) { @@ -49,22 +45,7 @@ public void enrich(final LoanChargeDataV1 data) { return; } - final List mappings = loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(data.getLoanId()); - if (mappings == null || mappings.isEmpty()) { - return; - } - - final List originators = new ArrayList<>(); - for (LoanOriginatorMapping mapping : mappings) { - final LoanOriginator originator = mapping.getOriginator(); - if (originator != null) { - final OriginatorDetailsV1 originatorDetails = loanOriginatorAvroMapper.toAvro(originator); - if (originatorDetails != null) { - originators.add(originatorDetails); - } - } - } - + final List originators = loanOriginatorDetailsResolver.resolveOriginatorDetails(data.getLoanId()); if (!originators.isEmpty()) { data.setOriginators(originators); } diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionAdjustmentDataV1OriginatorEnricher.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionAdjustmentDataV1OriginatorEnricher.java new file mode 100644 index 00000000000..44858f16184 --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionAdjustmentDataV1OriginatorEnricher.java @@ -0,0 +1,59 @@ +/** + * 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.portfolio.loanorigination.enricher; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.avro.loan.v1.LoanTransactionAdjustmentDataV1; +import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1; +import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; +import org.apache.fineract.infrastructure.core.service.DataEnricher; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class LoanTransactionAdjustmentDataV1OriginatorEnricher implements DataEnricher { + + private final LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; + + @Override + public boolean isDataTypeSupported(final Class dataType) { + return dataType.isAssignableFrom(LoanTransactionAdjustmentDataV1.class); + } + + @Override + public void enrich(final LoanTransactionAdjustmentDataV1 data) { + final LoanTransactionDataV1 transactionToAdjust = data.getTransactionToAdjust(); + if (transactionToAdjust == null || transactionToAdjust.getLoanId() == null) { + return; + } + + final List originators = loanOriginatorDetailsResolver + .resolveOriginatorDetails(transactionToAdjust.getLoanId()); + if (!originators.isEmpty()) { + transactionToAdjust.setOriginators(originators); + if (data.getNewTransactionDetail() != null) { + data.getNewTransactionDetail().setOriginators(originators); + } + } + } +} diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricher.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricher.java index 721932d3eeb..dda7e3adf90 100644 --- a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricher.java +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricher.java @@ -18,15 +18,12 @@ */ package org.apache.fineract.portfolio.loanorigination.enricher; -import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1; import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; import org.apache.fineract.infrastructure.core.service.DataEnricher; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -35,8 +32,7 @@ @ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") public class LoanTransactionDataV1OriginatorEnricher implements DataEnricher { - private final LoanOriginatorMappingRepository loanOriginatorMappingRepository; - private final LoanOriginatorAvroMapper loanOriginatorAvroMapper; + private final LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; @Override public boolean isDataTypeSupported(final Class dataType) { @@ -49,22 +45,7 @@ public void enrich(final LoanTransactionDataV1 data) { return; } - final List mappings = loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(data.getLoanId()); - if (mappings == null || mappings.isEmpty()) { - return; - } - - final List originators = new ArrayList<>(); - for (LoanOriginatorMapping mapping : mappings) { - final LoanOriginator originator = mapping.getOriginator(); - if (originator != null) { - final OriginatorDetailsV1 originatorDetails = loanOriginatorAvroMapper.toAvro(originator); - if (originatorDetails != null) { - originators.add(originatorDetails); - } - } - } - + final List originators = loanOriginatorDetailsResolver.resolveOriginatorDetails(data.getLoanId()); if (!originators.isEmpty()) { data.setOriginators(originators); } diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/helper/LoanOriginatorDetailsResolver.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/helper/LoanOriginatorDetailsResolver.java new file mode 100644 index 00000000000..57db2df33ac --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/helper/LoanOriginatorDetailsResolver.java @@ -0,0 +1,69 @@ +/** + * 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.portfolio.loanorigination.helper; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.enricher.LoanOriginatorAvroMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Resolves originator details for a given loan by fetching originator mappings and converting them to Avro format. + */ +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class LoanOriginatorDetailsResolver { + + private final LoanOriginatorMappingRepository loanOriginatorMappingRepository; + private final LoanOriginatorAvroMapper loanOriginatorAvroMapper; + + /** + * Fetches originator mappings for the given loan ID and converts them to a list of {@link OriginatorDetailsV1}. + * + * @param loanId + * the loan ID to resolve originators for + * @return the list of originator details, or an empty list if none are found + */ + public List resolveOriginatorDetails(final Long loanId) { + final List mappings = loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId); + if (mappings == null || mappings.isEmpty()) { + return List.of(); + } + + final List originators = new ArrayList<>(); + for (LoanOriginatorMapping mapping : mappings) { + final LoanOriginator originator = mapping.getOriginator(); + if (originator != null) { + final OriginatorDetailsV1 originatorDetails = loanOriginatorAvroMapper.toAvro(originator); + if (originatorDetails != null) { + originators.add(originatorDetails); + } + } + } + + return List.copyOf(originators); + } +} diff --git a/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricherTest.java b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricherTest.java index 0dee5f95adb..ab74ed417a7 100644 --- a/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricherTest.java +++ b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanAccountDataV1OriginatorEnricherTest.java @@ -23,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,11 +31,7 @@ import java.util.List; import org.apache.fineract.avro.loan.v1.LoanAccountDataV1; import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; -import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -48,10 +43,7 @@ class LoanAccountDataV1OriginatorEnricherTest { @Mock - private LoanOriginatorMappingRepository loanOriginatorMappingRepository; - - @Mock - private LoanOriginatorAvroMapper loanOriginatorAvroMapper; + private LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; @InjectMocks private LoanAccountDataV1OriginatorEnricher enricher; @@ -74,21 +66,14 @@ void testIsDataTypeSupported() { @Test void testEnrich_WithOriginators() { // Given - final LoanOriginator originator = createTestOriginator(1L, "test-originator-1", "Test Originator 1"); - final LoanOriginatorMapping mapping = LoanOriginatorMapping.create(loanId, originator); - final List mappings = List.of(mapping); - final OriginatorDetailsV1 originatorDetails = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); - - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(mappings); - when(loanOriginatorAvroMapper.toAvro(originator)).thenReturn(originatorDetails); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(originatorDetails)); // When enricher.enrich(loanAccountData); // Then - verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); - verify(loanOriginatorAvroMapper).toAvro(originator); + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); assertNotNull(loanAccountData.getOriginators()); assertEquals(1, loanAccountData.getOriginators().size()); assertEquals("test-originator-1", loanAccountData.getOriginators().getFirst().getExternalId()); @@ -97,17 +82,9 @@ void testEnrich_WithOriginators() { @Test void testEnrich_WithMultipleOriginators() { // Given - final LoanOriginator originator1 = createTestOriginator(1L, "test-originator-1", "Test Originator 1"); - final LoanOriginator originator2 = createTestOriginator(2L, "test-originator-2", "Test Originator 2"); - final List mappings = List.of(LoanOriginatorMapping.create(loanId, originator1), - LoanOriginatorMapping.create(loanId, originator2)); - final OriginatorDetailsV1 details1 = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); final OriginatorDetailsV1 details2 = createOriginatorDetailsV1(2L, "test-originator-2", "Test Originator 2"); - - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(mappings); - when(loanOriginatorAvroMapper.toAvro(originator1)).thenReturn(details1); - when(loanOriginatorAvroMapper.toAvro(originator2)).thenReturn(details2); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(details1, details2)); // When enricher.enrich(loanAccountData); @@ -120,14 +97,13 @@ void testEnrich_WithMultipleOriginators() { @Test void testEnrich_NoOriginators() { // Given - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(Collections.emptyList()); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(Collections.emptyList()); // When enricher.enrich(loanAccountData); // Then - verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); - verify(loanOriginatorAvroMapper, never()).toAvro(any()); + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); assertNull(loanAccountData.getOriginators()); } @@ -140,7 +116,7 @@ void testEnrich_NullLoanId() { enricher.enrich(loanAccountData); // Then - verify(loanOriginatorMappingRepository, never()).findByLoanIdWithOriginatorDetails(any()); + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); assertNull(loanAccountData.getOriginators()); } @@ -150,45 +126,7 @@ void testEnrich_NullData() { enricher.enrich(null); // Then - verify(loanOriginatorMappingRepository, never()).findByLoanIdWithOriginatorDetails(any()); - } - - @Test - void testEnrich_NullMappings() { - // Given - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(null); - - // When - enricher.enrich(loanAccountData); - - // Then - verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); - verify(loanOriginatorAvroMapper, never()).toAvro(any()); - assertNull(loanAccountData.getOriginators()); - } - - @Test - void testEnrich_NullOriginatorInMapping() { - // Given - final LoanOriginatorMapping mapping = mock(LoanOriginatorMapping.class); - when(mapping.getOriginator()).thenReturn(null); - final List mappings = List.of(mapping); - - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(mappings); - - // When - enricher.enrich(loanAccountData); - - // Then - verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); - verify(loanOriginatorAvroMapper, never()).toAvro(any()); - assertNull(loanAccountData.getOriginators()); - } - - private LoanOriginator createTestOriginator(final Long id, final String externalId, final String name) { - final LoanOriginator originator = LoanOriginator.create(new ExternalId(externalId), name, LoanOriginatorStatus.ACTIVE, null, null); - originator.setId(id); - return originator; + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); } private OriginatorDetailsV1 createOriginatorDetailsV1(final Long id, final String externalId, final String name) { diff --git a/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanChargeDataV1OriginatorEnricherTest.java b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanChargeDataV1OriginatorEnricherTest.java new file mode 100644 index 00000000000..af866a9797e --- /dev/null +++ b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanChargeDataV1OriginatorEnricherTest.java @@ -0,0 +1,136 @@ +/** + * 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.portfolio.loanorigination.enricher; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import org.apache.fineract.avro.loan.v1.LoanChargeDataV1; +import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LoanChargeDataV1OriginatorEnricherTest { + + @Mock + private LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; + + @InjectMocks + private LoanChargeDataV1OriginatorEnricher enricher; + + private LoanChargeDataV1 loanChargeData; + private Long loanId; + + @BeforeEach + void setUp() { + loanId = 1L; + loanChargeData = new LoanChargeDataV1(); + loanChargeData.setLoanId(loanId); + } + + @Test + void testIsDataTypeSupported() { + assertTrue(enricher.isDataTypeSupported(LoanChargeDataV1.class)); + } + + @Test + void testEnrich_WithOriginators() { + // Given + final OriginatorDetailsV1 originatorDetails = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(originatorDetails)); + + // When + enricher.enrich(loanChargeData); + + // Then + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); + assertNotNull(loanChargeData.getOriginators()); + assertEquals(1, loanChargeData.getOriginators().size()); + assertEquals("test-originator-1", loanChargeData.getOriginators().getFirst().getExternalId()); + } + + @Test + void testEnrich_WithMultipleOriginators() { + // Given + final OriginatorDetailsV1 details1 = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); + final OriginatorDetailsV1 details2 = createOriginatorDetailsV1(2L, "test-originator-2", "Test Originator 2"); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(details1, details2)); + + // When + enricher.enrich(loanChargeData); + + // Then + assertNotNull(loanChargeData.getOriginators()); + assertEquals(2, loanChargeData.getOriginators().size()); + } + + @Test + void testEnrich_NoOriginators() { + // Given + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(Collections.emptyList()); + + // When + enricher.enrich(loanChargeData); + + // Then + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); + assertNull(loanChargeData.getOriginators()); + } + + @Test + void testEnrich_NullLoanId() { + // Given + loanChargeData.setLoanId(null); + + // When + enricher.enrich(loanChargeData); + + // Then + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); + assertNull(loanChargeData.getOriginators()); + } + + @Test + void testEnrich_NullData() { + // When + enricher.enrich(null); + + // Then + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); + } + + private OriginatorDetailsV1 createOriginatorDetailsV1(final Long id, final String externalId, final String name) { + return OriginatorDetailsV1.newBuilder().setId(id).setExternalId(externalId).setName(name).setStatus("ACTIVE") + .setOriginatorType(null).setChannelType(null).build(); + } +} diff --git a/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionAdjustmentDataV1OriginatorEnricherTest.java b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionAdjustmentDataV1OriginatorEnricherTest.java new file mode 100644 index 00000000000..39cb7786081 --- /dev/null +++ b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionAdjustmentDataV1OriginatorEnricherTest.java @@ -0,0 +1,168 @@ +/** + * 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.portfolio.loanorigination.enricher; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import org.apache.fineract.avro.loan.v1.LoanTransactionAdjustmentDataV1; +import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1; +import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LoanTransactionAdjustmentDataV1OriginatorEnricherTest { + + @Mock + private LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; + + @InjectMocks + private LoanTransactionAdjustmentDataV1OriginatorEnricher enricher; + + private LoanTransactionAdjustmentDataV1 adjustmentData; + private LoanTransactionDataV1 transactionToAdjust; + private LoanTransactionDataV1 newTransactionDetail; + private Long loanId; + + @BeforeEach + void setUp() { + loanId = 1L; + transactionToAdjust = new LoanTransactionDataV1(); + transactionToAdjust.setLoanId(loanId); + newTransactionDetail = new LoanTransactionDataV1(); + adjustmentData = new LoanTransactionAdjustmentDataV1(); + adjustmentData.setTransactionToAdjust(transactionToAdjust); + adjustmentData.setNewTransactionDetail(newTransactionDetail); + } + + @Test + void testIsDataTypeSupported() { + assertTrue(enricher.isDataTypeSupported(LoanTransactionAdjustmentDataV1.class)); + } + + @Test + void testEnrich_WithOriginators() { + // Given + final OriginatorDetailsV1 originatorDetails = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(originatorDetails)); + + // When + enricher.enrich(adjustmentData); + + // Then + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); + assertNotNull(transactionToAdjust.getOriginators()); + assertEquals(1, transactionToAdjust.getOriginators().size()); + assertEquals("test-originator-1", transactionToAdjust.getOriginators().getFirst().getExternalId()); + assertNotNull(newTransactionDetail.getOriginators()); + assertEquals(1, newTransactionDetail.getOriginators().size()); + assertEquals("test-originator-1", newTransactionDetail.getOriginators().getFirst().getExternalId()); + } + + @Test + void testEnrich_WithMultipleOriginators() { + // Given + final OriginatorDetailsV1 details1 = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); + final OriginatorDetailsV1 details2 = createOriginatorDetailsV1(2L, "test-originator-2", "Test Originator 2"); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(details1, details2)); + + // When + enricher.enrich(adjustmentData); + + // Then + assertNotNull(transactionToAdjust.getOriginators()); + assertEquals(2, transactionToAdjust.getOriginators().size()); + assertNotNull(newTransactionDetail.getOriginators()); + assertEquals(2, newTransactionDetail.getOriginators().size()); + } + + @Test + void testEnrich_WithOriginators_NullNewTransactionDetail() { + // Given + adjustmentData.setNewTransactionDetail(null); + final OriginatorDetailsV1 originatorDetails = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(originatorDetails)); + + // When + enricher.enrich(adjustmentData); + + // Then + assertNotNull(transactionToAdjust.getOriginators()); + assertEquals(1, transactionToAdjust.getOriginators().size()); + assertNull(adjustmentData.getNewTransactionDetail()); + } + + @Test + void testEnrich_NoOriginators() { + // Given + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(Collections.emptyList()); + + // When + enricher.enrich(adjustmentData); + + // Then + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); + assertNull(transactionToAdjust.getOriginators()); + assertNull(newTransactionDetail.getOriginators()); + } + + @Test + void testEnrich_NullTransactionToAdjust() { + // Given + adjustmentData.setTransactionToAdjust(null); + + // When + enricher.enrich(adjustmentData); + + // Then + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); + } + + @Test + void testEnrich_NullLoanId() { + // Given + transactionToAdjust.setLoanId(null); + + // When + enricher.enrich(adjustmentData); + + // Then + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); + assertNull(transactionToAdjust.getOriginators()); + } + + private OriginatorDetailsV1 createOriginatorDetailsV1(final Long id, final String externalId, final String name) { + return OriginatorDetailsV1.newBuilder().setId(id).setExternalId(externalId).setName(name).setStatus("ACTIVE") + .setOriginatorType(null).setChannelType(null).build(); + } +} diff --git a/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricherTest.java b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricherTest.java index 901b84fc32f..42044c9d315 100644 --- a/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricherTest.java +++ b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/enricher/LoanTransactionDataV1OriginatorEnricherTest.java @@ -31,11 +31,7 @@ import java.util.List; import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1; import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; -import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus; +import org.apache.fineract.portfolio.loanorigination.helper.LoanOriginatorDetailsResolver; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -47,10 +43,7 @@ class LoanTransactionDataV1OriginatorEnricherTest { @Mock - private LoanOriginatorMappingRepository loanOriginatorMappingRepository; - - @Mock - private LoanOriginatorAvroMapper loanOriginatorAvroMapper; + private LoanOriginatorDetailsResolver loanOriginatorDetailsResolver; @InjectMocks private LoanTransactionDataV1OriginatorEnricher enricher; @@ -73,21 +66,14 @@ void testIsDataTypeSupported() { @Test void testEnrich_WithOriginators() { // Given - final LoanOriginator originator = createTestOriginator(1L, "test-originator-1", "Test Originator 1"); - final LoanOriginatorMapping mapping = LoanOriginatorMapping.create(loanId, originator); - final List mappings = List.of(mapping); - final OriginatorDetailsV1 originatorDetails = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); - - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(mappings); - when(loanOriginatorAvroMapper.toAvro(originator)).thenReturn(originatorDetails); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(originatorDetails)); // When enricher.enrich(loanTransactionData); // Then - verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); - verify(loanOriginatorAvroMapper).toAvro(originator); + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); assertNotNull(loanTransactionData.getOriginators()); assertEquals(1, loanTransactionData.getOriginators().size()); assertEquals("test-originator-1", loanTransactionData.getOriginators().getFirst().getExternalId()); @@ -96,17 +82,9 @@ void testEnrich_WithOriginators() { @Test void testEnrich_WithMultipleOriginators() { // Given - final LoanOriginator originator1 = createTestOriginator(1L, "test-originator-1", "Test Originator 1"); - final LoanOriginator originator2 = createTestOriginator(2L, "test-originator-2", "Test Originator 2"); - final List mappings = List.of(LoanOriginatorMapping.create(loanId, originator1), - LoanOriginatorMapping.create(loanId, originator2)); - final OriginatorDetailsV1 details1 = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); final OriginatorDetailsV1 details2 = createOriginatorDetailsV1(2L, "test-originator-2", "Test Originator 2"); - - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(mappings); - when(loanOriginatorAvroMapper.toAvro(originator1)).thenReturn(details1); - when(loanOriginatorAvroMapper.toAvro(originator2)).thenReturn(details2); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(List.of(details1, details2)); // When enricher.enrich(loanTransactionData); @@ -119,14 +97,13 @@ void testEnrich_WithMultipleOriginators() { @Test void testEnrich_NoOriginators() { // Given - when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(Collections.emptyList()); + when(loanOriginatorDetailsResolver.resolveOriginatorDetails(loanId)).thenReturn(Collections.emptyList()); // When enricher.enrich(loanTransactionData); // Then - verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); - verify(loanOriginatorAvroMapper, never()).toAvro(any()); + verify(loanOriginatorDetailsResolver).resolveOriginatorDetails(loanId); assertNull(loanTransactionData.getOriginators()); } @@ -139,7 +116,7 @@ void testEnrich_NullLoanId() { enricher.enrich(loanTransactionData); // Then - verify(loanOriginatorMappingRepository, never()).findByLoanIdWithOriginatorDetails(any()); + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); assertNull(loanTransactionData.getOriginators()); } @@ -149,13 +126,7 @@ void testEnrich_NullData() { enricher.enrich(null); // Then - verify(loanOriginatorMappingRepository, never()).findByLoanIdWithOriginatorDetails(any()); - } - - private LoanOriginator createTestOriginator(final Long id, final String externalId, final String name) { - final LoanOriginator originator = LoanOriginator.create(new ExternalId(externalId), name, LoanOriginatorStatus.ACTIVE, null, null); - originator.setId(id); - return originator; + verify(loanOriginatorDetailsResolver, never()).resolveOriginatorDetails(any()); } private OriginatorDetailsV1 createOriginatorDetailsV1(final Long id, final String externalId, final String name) { diff --git a/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/helper/LoanOriginatorDetailsResolverTest.java b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/helper/LoanOriginatorDetailsResolverTest.java new file mode 100644 index 00000000000..1c2f1b0f77f --- /dev/null +++ b/fineract-loan-origination/src/test/java/org/apache/fineract/portfolio/loanorigination/helper/LoanOriginatorDetailsResolverTest.java @@ -0,0 +1,176 @@ +/** + * 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.portfolio.loanorigination.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus; +import org.apache.fineract.portfolio.loanorigination.enricher.LoanOriginatorAvroMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LoanOriginatorDetailsResolverTest { + + @Mock + private LoanOriginatorMappingRepository loanOriginatorMappingRepository; + + @Mock + private LoanOriginatorAvroMapper loanOriginatorAvroMapper; + + @InjectMocks + private LoanOriginatorDetailsResolver resolver; + + @Test + void testResolveOriginatorDetails_WithSingleOriginator() { + // Given + final Long loanId = 1L; + final LoanOriginator originator = createTestOriginator(1L, "test-originator-1", "Test Originator 1"); + final LoanOriginatorMapping mapping = LoanOriginatorMapping.create(loanId, originator); + final OriginatorDetailsV1 originatorDetails = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); + + when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(List.of(mapping)); + when(loanOriginatorAvroMapper.toAvro(originator)).thenReturn(originatorDetails); + + // When + final List result = resolver.resolveOriginatorDetails(loanId); + + // Then + verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); + verify(loanOriginatorAvroMapper).toAvro(originator); + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("test-originator-1", result.getFirst().getExternalId()); + } + + @Test + void testResolveOriginatorDetails_WithMultipleOriginators() { + // Given + final Long loanId = 1L; + final LoanOriginator originator1 = createTestOriginator(1L, "test-originator-1", "Test Originator 1"); + final LoanOriginator originator2 = createTestOriginator(2L, "test-originator-2", "Test Originator 2"); + final OriginatorDetailsV1 details1 = createOriginatorDetailsV1(1L, "test-originator-1", "Test Originator 1"); + final OriginatorDetailsV1 details2 = createOriginatorDetailsV1(2L, "test-originator-2", "Test Originator 2"); + + when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)) + .thenReturn(List.of(LoanOriginatorMapping.create(loanId, originator1), LoanOriginatorMapping.create(loanId, originator2))); + when(loanOriginatorAvroMapper.toAvro(originator1)).thenReturn(details1); + when(loanOriginatorAvroMapper.toAvro(originator2)).thenReturn(details2); + + // When + final List result = resolver.resolveOriginatorDetails(loanId); + + // Then + assertNotNull(result); + assertEquals(2, result.size()); + } + + @Test + void testResolveOriginatorDetails_EmptyMappings() { + // Given + final Long loanId = 1L; + when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(Collections.emptyList()); + + // When + final List result = resolver.resolveOriginatorDetails(loanId); + + // Then + verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); + verify(loanOriginatorAvroMapper, never()).toAvro(any()); + assertTrue(result.isEmpty()); + } + + @Test + void testResolveOriginatorDetails_NullMappings() { + // Given + final Long loanId = 1L; + when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(null); + + // When + final List result = resolver.resolveOriginatorDetails(loanId); + + // Then + verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); + verify(loanOriginatorAvroMapper, never()).toAvro(any()); + assertTrue(result.isEmpty()); + } + + @Test + void testResolveOriginatorDetails_NullOriginatorInMapping() { + // Given + final Long loanId = 1L; + final LoanOriginatorMapping mapping = mock(LoanOriginatorMapping.class); + when(mapping.getOriginator()).thenReturn(null); + when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(List.of(mapping)); + + // When + final List result = resolver.resolveOriginatorDetails(loanId); + + // Then + verify(loanOriginatorMappingRepository).findByLoanIdWithOriginatorDetails(loanId); + verify(loanOriginatorAvroMapper, never()).toAvro(any()); + assertTrue(result.isEmpty()); + } + + @Test + void testResolveOriginatorDetails_MapperReturnsNull() { + // Given + final Long loanId = 1L; + final LoanOriginator originator = createTestOriginator(1L, "test-originator-1", "Test Originator 1"); + final LoanOriginatorMapping mapping = LoanOriginatorMapping.create(loanId, originator); + + when(loanOriginatorMappingRepository.findByLoanIdWithOriginatorDetails(loanId)).thenReturn(List.of(mapping)); + when(loanOriginatorAvroMapper.toAvro(originator)).thenReturn(null); + + // When + final List result = resolver.resolveOriginatorDetails(loanId); + + // Then + verify(loanOriginatorAvroMapper).toAvro(originator); + assertTrue(result.isEmpty()); + } + + private LoanOriginator createTestOriginator(final Long id, final String externalId, final String name) { + final LoanOriginator originator = LoanOriginator.create(new ExternalId(externalId), name, LoanOriginatorStatus.ACTIVE, null, null); + originator.setId(id); + return originator; + } + + private OriginatorDetailsV1 createOriginatorDetailsV1(final Long id, final String externalId, final String name) { + return OriginatorDetailsV1.newBuilder().setId(id).setExternalId(externalId).setName(name).setStatus("ACTIVE") + .setOriginatorType(null).setChannelType(null).build(); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index 52a259251cb..391c52987e7 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -150,7 +150,7 @@ public void givenNoEventConfigurationWhenValidatedThenThrowException() throws Ex () -> underTest.afterPropertiesSet()); // then - String expectedMessage = "No external events configured"; + String expectedMessage = "Configuration not found for external event LoanAccountsStayedLockedBusinessEvent"; String actualMessage = exceptionThrown.getMessage(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventTestValidators.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventTestValidators.java new file mode 100644 index 00000000000..cd1bcfe6d7e --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventTestValidators.java @@ -0,0 +1,94 @@ +/** + * 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.integrationtests.client.feign.modules; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.event.external.data.ExternalEventResponse; + +/** + * Static assertion methods for verifying external business event payloads. + */ +public final class ExternalEventTestValidators { + + private ExternalEventTestValidators() {} + + /** + * Finds the event matching the given loan (by aggregateRootId) from a list of events. + */ + public static ExternalEventResponse findEventForLoan(List events, Long loanId) { + assertThat(events).as("Expected at least one external event").isNotEmpty(); + ExternalEventResponse event = events.stream().filter(e -> loanId.equals(e.getAggregateRootId())).findFirst().orElse(null); + assertThat(event).as("Expected an event for loan %d", loanId).isNotNull(); + return event; + } + + /** + * Asserts that the event payload contains originator details with the given external IDs at the top level + */ + public static void assertOriginators(ExternalEventResponse event, String... expectedExternalIds) { + assertOriginatorsAtPath(event.getPayLoad(), expectedExternalIds); + } + + /** + * Asserts that the event payload contains originator details at a nested path (e.g. "transactionToAdjust" for + * LoanTransactionAdjustmentDataV1). + */ + public static void assertOriginatorsInField(ExternalEventResponse event, String nestedField, String... expectedExternalIds) { + Map nested = getNestedMap(event.getPayLoad(), nestedField); + assertOriginatorsAtPath(nested, expectedExternalIds); + } + + /** + * Asserts that originators is null at the top level of the event payload. + */ + public static void assertNoOriginators(ExternalEventResponse event) { + assertThat(event.getPayLoad().get("originators")).as("Expected no originators in event payload").isNull(); + } + + /** + * Asserts that originators is null at a nested path in the event payload. + */ + public static void assertNoOriginatorsInField(ExternalEventResponse event, String nestedField) { + Map nested = getNestedMap(event.getPayLoad(), nestedField); + assertThat(nested.get("originators")).as("Expected no originators in '%s'", nestedField).isNull(); + } + + @SuppressWarnings("unchecked") + private static Map getNestedMap(Map payload, String field) { + Object nested = payload.get(field); + assertThat(nested).as("Expected field '%s' in payload", field).isNotNull().isInstanceOf(Map.class); + return (Map) nested; + } + + @SuppressWarnings("unchecked") + private static void assertOriginatorsAtPath(Map data, String... expectedExternalIds) { + Object originators = data.get("originators"); + assertThat(originators).as("Expected originators to be present").isNotNull().isInstanceOf(List.class); + + List> originatorList = (List>) originators; + assertThat(originatorList).hasSize(expectedExternalIds.length); + + for (int i = 0; i < expectedExternalIds.length; i++) { + assertThat(originatorList.get(i).get("externalId")).as("Originator[%d].externalId", i).isEqualTo(expectedExternalIds[i]); + } + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanAdjustmentOriginatorEnricherTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanAdjustmentOriginatorEnricherTest.java new file mode 100644 index 00000000000..8e88431ff5f --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanAdjustmentOriginatorEnricherTest.java @@ -0,0 +1,202 @@ +/** + * 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.integrationtests.client.feign.tests; + +import java.util.List; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdRequest; +import org.apache.fineract.client.models.PostLoansLoanIdChargesRequest; +import org.apache.fineract.infrastructure.event.external.data.ExternalEventResponse; +import org.apache.fineract.integrationtests.client.feign.FeignLoanTestBase; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignExternalEventHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignLoanOriginatorHelper; +import org.apache.fineract.integrationtests.client.feign.modules.ExternalEventTestValidators; +import org.apache.fineract.integrationtests.client.feign.modules.LoanRequestBuilders; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class FeignLoanAdjustmentOriginatorEnricherTest extends FeignLoanTestBase { + + private static final String ADJUST_EVENT = "LoanAdjustTransactionBusinessEvent"; + private static final String ACCRUAL_EVENT = "LoanAccrualTransactionCreatedBusinessEvent"; + + private static FineractFeignClient fineractClient; + private static FeignLoanOriginatorHelper originatorHelper; + private static FeignExternalEventHelper externalEventHelper; + + @BeforeAll + public static void setupOriginatorHelpers() { + fineractClient = FineractFeignClientHelper.getFineractFeignClient(); + originatorHelper = new FeignLoanOriginatorHelper(fineractClient); + externalEventHelper = new FeignExternalEventHelper(fineractClient); + } + + @Test + public void testLoanAdjustTransactionEventContainsOriginators() { + externalEventHelper.enableBusinessEvent(ADJUST_EVENT); + try { + final String originatorExternalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId, "Test Originator", "ACTIVE"); + final Long clientId = createClient(); + final Long productId = loanHelper.createSimpleLoanProduct(); + final String today = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); + + // Create loan, attach originator, approve, disburse + final Long loanId = loanHelper.createSubmittedLoan(clientId, productId, today, 10000.0, 12); + originatorHelper.attachOriginatorToLoan(loanId, originatorId); + approveLoan(loanId, LoanRequestBuilders.approveLoan(10000.0, today)); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(10000.0, today)); + + final Long repaymentId = addRepayment(loanId, repayment(500.0, today)); + externalEventHelper.deleteAllExternalEvents(); + + // When: the repayment is undone + undoRepayment(loanId, repaymentId, today); + + // Then + final List events = externalEventHelper.getExternalEventsByType(ADJUST_EVENT); + final ExternalEventResponse event = ExternalEventTestValidators.findEventForLoan(events, loanId); + ExternalEventTestValidators.assertOriginatorsInField(event, "transactionToAdjust", originatorExternalId); + } finally { + externalEventHelper.disableBusinessEvent(ADJUST_EVENT); + } + } + + @Test + public void testLoanAdjustTransactionEventWithNoOriginators() { + externalEventHelper.enableBusinessEvent(ADJUST_EVENT); + try { + final Long clientId = createClient(); + final Long productId = loanHelper.createSimpleLoanProduct(); + final String today = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); + + final Long loanId = createApproveAndDisburseLoan(clientId, productId, today, 10000.0, 12); + final Long repaymentId = addRepayment(loanId, repayment(500.0, today)); + externalEventHelper.deleteAllExternalEvents(); + + // When: the repayment is undone + undoRepayment(loanId, repaymentId, today); + + // Then + final List events = externalEventHelper.getExternalEventsByType(ADJUST_EVENT); + final ExternalEventResponse event = ExternalEventTestValidators.findEventForLoan(events, loanId); + ExternalEventTestValidators.assertNoOriginatorsInField(event, "transactionToAdjust"); + } finally { + externalEventHelper.disableBusinessEvent(ADJUST_EVENT); + } + } + + @Test + public void testAccrualEventContainsOriginators() { + externalEventHelper.enableBusinessEvent(ACCRUAL_EVENT); + try { + final String originatorExternalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId, "Test Originator", "ACTIVE"); + final Long clientId = createClient(); + final Long productId = createLoanProduct(fourInstallmentsCumulative()); + final String today = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); + + // Create loan with accrual accounting, attach originator, approve, disburse + final Long loanId = loanHelper.createSubmittedLoan(clientId, productId, today, 10000.0, 4); + originatorHelper.attachOriginatorToLoan(loanId, originatorId); + approveLoan(loanId, LoanRequestBuilders.approveLoan(10000.0, today)); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(10000.0, today)); + + // Add a fee charge to the loan + final Long chargeId = createFlatFeeCharge(100.0, "EUR"); + ok(() -> fineractClient.loanCharges().executeLoanCharge(loanId, new PostLoansLoanIdChargesRequest().chargeId(chargeId) + .amount(100.0).locale("en").dateFormat("dd MMMM yyyy").dueDate(today), (String) null)); + + externalEventHelper.deleteAllExternalEvents(); + + // When: COB runs and creates accrual transactions + executeInlineCOB(loanId); + + // Then: the accrual event contains originator details + final List events = externalEventHelper.getExternalEventsByType(ACCRUAL_EVENT); + final ExternalEventResponse event = ExternalEventTestValidators.findEventForLoan(events, loanId); + ExternalEventTestValidators.assertOriginators(event, originatorExternalId); + } finally { + externalEventHelper.disableBusinessEvent(ACCRUAL_EVENT); + } + } + + @Test + public void testAdjustEventContainsOriginatorsAfterChargeWaiverReversal() { + externalEventHelper.enableBusinessEvent(ADJUST_EVENT); + try { + final String originatorExternalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId, "Test Originator", "ACTIVE"); + final Long clientId = createClient(); + final Long productId = createLoanProduct(fourInstallmentsCumulative()); + final String today = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); + + // Create loan with accrual accounting, attach originator, approve, disburse + final Long loanId = loanHelper.createSubmittedLoan(clientId, productId, today, 10000.0, 4); + originatorHelper.attachOriginatorToLoan(loanId, originatorId); + approveLoan(loanId, LoanRequestBuilders.approveLoan(10000.0, today)); + disburseLoan(loanId, LoanRequestBuilders.disburseLoan(10000.0, today)); + + // Add a fee charge and waive it + final Long chargeId = createFlatFeeCharge(100.0, "EUR"); + final Long loanChargeId = ok(() -> fineractClient.loanCharges().executeLoanCharge(loanId, new PostLoansLoanIdChargesRequest() + .chargeId(chargeId).amount(100.0).locale("en").dateFormat("dd MMMM yyyy").dueDate(today), (String) null)) + .getResourceId(); + + ok(() -> fineractClient.loanCharges().executeLoanChargeOnExistingCharge(loanId, loanChargeId, + new PostLoansLoanIdChargesChargeIdRequest(), "waive")); + + // Find the waiver transaction from loan details + final GetLoansLoanIdResponse loanAfterWaive = getLoanDetails(loanId); + final Long waiveTransactionId = loanAfterWaive.getTransactions().stream() + .filter(t -> "loanTransactionType.waiveCharges".equals(t.getType().getCode())).map(GetLoansLoanIdTransactions::getId) + .findFirst().orElseThrow(() -> new AssertionError("Waiver transaction not found")); + + externalEventHelper.deleteAllExternalEvents(); + + // When: the waiver is reversed + undoRepayment(loanId, waiveTransactionId, today); + + // Then: the adjustment event contains originator details + final List events = externalEventHelper.getExternalEventsByType(ADJUST_EVENT); + final ExternalEventResponse event = ExternalEventTestValidators.findEventForLoan(events, loanId); + ExternalEventTestValidators.assertOriginatorsInField(event, "transactionToAdjust", originatorExternalId); + } finally { + externalEventHelper.disableBusinessEvent(ADJUST_EVENT); + } + } + + private Long createFlatFeeCharge(double amount, String currencyCode) { + return ok(() -> fineractClient.charges().createCharge(new ChargeRequest()// + .name("Originator Test Fee " + System.currentTimeMillis())// + .currencyCode(currencyCode)// + .chargeAppliesTo(1)// + .chargeTimeType(2)// + .chargeCalculationType(1)// + .chargePaymentMode(0)// + .amount(amount)// + .active(true)// + .locale("en"))).getResourceId(); + } +}