From 2cfc0b2503cd4cf62a5bd9a3406cf7d21bbeb41b Mon Sep 17 00:00:00 2001 From: "adam.magyari" Date: Wed, 5 Mar 2025 15:54:20 +0100 Subject: [PATCH] FINERACT-2203: LoanTransactionsApi add getLoanTransactions --- .../api/jersey/PageableParamProvider.java | 107 ++++++++++++ .../core/api/jersey/Pagination.java | 38 ++++ .../test/helper/ErrorMessageHelper.java | 4 + .../test/stepdef/loan/LoanStepDef.java | 162 ++++++++++++++++++ .../features/LoanAccrualActivity.feature | 50 ++++++ .../api/LoanTransactionApiConstants.java | 56 ++++++ .../LoanTransactionsApiResourceSwagger.java | 7 + .../loanaccount/data/LoanTransactionData.java | 2 + .../domain/LoanRepositoryWrapper.java | 4 + .../loanaccount/domain/LoanTransaction.java | 17 -- .../mapper/LoanTransactionMapper.java | 51 ++++++ .../core/jersey/JerseyConfig.java | 11 ++ .../api/LoanTransactionsApiResource.java | 109 ++++++++++++ .../service/LoanReadPlatformService.java | 8 + .../service/LoanReadPlatformServiceImpl.java | 30 ++++ .../starter/LoanAccountConfiguration.java | 5 +- .../integrationtests/LoanTransactionTest.java | 89 ++++++++++ .../common/loans/LoanTransactionHelper.java | 32 ++++ 18 files changed, 763 insertions(+), 19 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/PageableParamProvider.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/Pagination.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTransactionMapper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/PageableParamProvider.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/PageableParamProvider.java new file mode 100644 index 000000000..be3783760 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/PageableParamProvider.java @@ -0,0 +1,107 @@ +/** + * 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.core.api.jersey; + +import com.google.common.base.Splitter; +import jakarta.ws.rs.core.MultivaluedMap; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import org.glassfish.jersey.server.ContainerRequest; +import org.glassfish.jersey.server.model.Parameter; +import org.glassfish.jersey.server.spi.internal.ValueParamProvider; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; + +public class PageableParamProvider implements ValueParamProvider { + + @Override + public Function getValueProvider(Parameter parameter) { + + if (parameter.getRawType() == Pageable.class && parameter.isAnnotationPresent(Pagination.class)) { + return new PageableFunction(parameter); + } + return null; + } + + @Override + public PriorityType getPriority() { + // Use HIGH otherwise it might not be used + return Priority.HIGH; + } + + private record PageableFunction(Parameter param) implements Function { + + @Override + public Pageable apply(ContainerRequest request) { + Pagination pagination = param.getAnnotation(Pagination.class); + MultivaluedMap queryParameters = request.getUriInfo().getQueryParameters(); + List sortingOrders = new ArrayList<>(); + AtomicInteger page = new AtomicInteger(0); + int pageSize = pagination.size() > pagination.maximumSize() ? pagination.maximumSize() : pagination.size(); + AtomicInteger size = new AtomicInteger(pageSize); + AtomicReference> sort = new AtomicReference<>(); + queryParameters.forEach((key, list) -> { + switch (key) { + case "page" -> page.set(Integer.parseInt(list.get(0))); + case "size" -> size.set(Integer.parseInt(list.get(0))); + case "sort" -> sort.set(list); + default -> { + } + } + }); + if (sort.get() != null) { + for (String propOrder : sort.get()) { + List propOrderSplit = Splitter.on(',').splitToList(propOrder); + String property = propOrderSplit.get(0); + if (propOrderSplit.size() == 1) { + sortingOrders.add(Sort.Order.by(property)); + } else { + Sort.Direction direction = Sort.Direction.fromString(propOrderSplit.get(1)); + sortingOrders.add(new Sort.Order(direction, property)); + } + } + } + + if (sortingOrders.isEmpty()) { + if (param.isAnnotationPresent(SortDefault.class)) { + SortDefault sortDefault = param.getAnnotation(SortDefault.class); + + for (String sortStr : sortDefault.sort()) { + sortingOrders.add(new Sort.Order(sortDefault.direction(), sortStr)); + } + } else if (param.isAnnotationPresent(SortDefault.SortDefaults.class)) { + SortDefault.SortDefaults sortDefaults = param.getAnnotation(SortDefault.SortDefaults.class); + + for (SortDefault sortDefault : sortDefaults.value()) { + for (String sortStr : sortDefault.sort()) { + sortingOrders.add(new Sort.Order(sortDefault.direction(), sortStr)); + } + } + } + } + + return PageRequest.of(page.get(), size.get(), Sort.by(sortingOrders)); + } + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/Pagination.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/Pagination.java new file mode 100644 index 000000000..f5bc10228 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/jersey/Pagination.java @@ -0,0 +1,38 @@ +/** + * 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.core.api.jersey; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Pagination { + + @AliasFor("size") + int value() default 50; + + @AliasFor("value") + int size() default 50; + + int maximumSize() default 10000; +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index bfb61c8a1..973310ace 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -896,4 +896,8 @@ public final class ErrorMessageHelper { public static String wrongExternalID(String actual, String expected) { return String.format("Wrong transaction External ID - %nActual value is: %s %nExpected value is: %s", actual, expected); } + + public static String wrongValueInTotalPages(Integer actual, Integer expected) { + return String.format("Wrong value for Total pages. %nActual value is: %s %nExpected value is: %s", actual, expected); + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java index ee5a4b37f..5dec10162 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java @@ -44,6 +44,8 @@ import java.time.Duration; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -74,6 +76,7 @@ import org.apache.fineract.client.models.GetLoansLoanIdRepaymentSchedule; import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdTimeline; import org.apache.fineract.client.models.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.GetLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTransactionIdResponse; import org.apache.fineract.client.models.InterestPauseRequestDto; import org.apache.fineract.client.models.IsCatchUpRunningResponse; @@ -3442,4 +3445,163 @@ public class LoanStepDef extends AbstractStepDef { } return actualValues; } + + @Then("Log out transaction list by loanId, filtered out the following transaction types: {string}") + public void transactionsExcluded(String excludedTypes) throws IOException { + Response loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanCreateResponse.body().getLoanId(); + Response transactionsByLoanIdFiltered = getTransactionsByLoanIdFiltered(loanId, excludedTypes); + ErrorHelper.checkSuccessfulApiCall(transactionsByLoanIdFiltered); + + List transactions = transactionsByLoanIdFiltered.body().getContent(); + log.info("Filtered transactions: {}", transactions); + + List excludedTypesList = Arrays.stream(excludedTypes.toLowerCase().split(",")).map(String::trim) + .collect(Collectors.toList()); + + // Verify no transaction with excluded types exists in the filtered list + for (GetLoansLoanIdTransactionsTransactionIdResponse transaction : transactions) { + String transactionType = transaction.getType().getCode(); + assertThat(excludedTypesList.contains(transactionType)) + .as(String.format("Transaction type '%s' should be excluded but was found in the filtered results", transactionType)) + .isFalse(); + } + } + + @Then("Log out transaction list by loanExternalId, filtered out the following transaction types: {string}") + public void transactionsExcludedByExternalId(String excludedTypes) throws IOException { + Response loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + String loanExternalId = loanCreateResponse.body().getResourceExternalId(); + Response transactionsByLoanExternalIdFiltered = getTransactionsByLoanIExternalIdFiltered( + loanExternalId, excludedTypes); + ErrorHelper.checkSuccessfulApiCall(transactionsByLoanExternalIdFiltered); + + List transactions = transactionsByLoanExternalIdFiltered.body().getContent(); + log.info("Filtered transactions: {}", transactions); + + List excludedTypesList = Arrays.stream(excludedTypes.toLowerCase().split(",")).map(String::trim) + .collect(Collectors.toList()); + + // Verify no transaction with excluded types exists in the filtered list + for (GetLoansLoanIdTransactionsTransactionIdResponse transaction : transactions) { + String transactionType = transaction.getType().getCode(); + assertThat(excludedTypesList.contains(transactionType)) + .as(String.format("Transaction type '%s' should be excluded but was found in the filtered results", transactionType)) + .isFalse(); + } + } + + @Then("Filtered out transactions list contains the the following entries when filtered out by loanId for transaction types: {string}") + public void transactionsExcludedCheck(String excludedTypes, DataTable table) throws IOException { + Response loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanCreateResponse.body().getLoanId(); + String resourceId = String.valueOf(loanId); + + Response transactionsByLoanIdFiltered = getTransactionsByLoanIdFiltered(loanId, excludedTypes); + ErrorHelper.checkSuccessfulApiCall(transactionsByLoanIdFiltered); + List transactions = transactionsByLoanIdFiltered.body().getContent(); + List> data = table.asLists(); + for (int i = 1; i < data.size(); i++) { + List expectedValues = data.get(i); + String transactionDateExpected = expectedValues.get(0); + List> actualValuesList = transactions.stream()// + .filter(t -> transactionDateExpected.equals(FORMATTER.format(t.getDate())))// + .map(t -> fetchValuesOfFilteredTransaction(table.row(0), t))// + .collect(Collectors.toList());// + boolean containsExpectedValues = actualValuesList.stream()// + .anyMatch(actualValues -> actualValues.equals(expectedValues));// + assertThat(containsExpectedValues) + .as(ErrorMessageHelper.wrongValueInLineInTransactionsTab(resourceId, i, actualValuesList, expectedValues)).isTrue(); + } + assertThat(transactions.size()) + .as(ErrorMessageHelper.nrOfLinesWrongInTransactionsTab(resourceId, transactions.size(), data.size() - 1)) + .isEqualTo(data.size() - 1); + } + + @Then("Filtered out transactions list contains the the following entries when filtered out by loanExternalId for transaction types: {string}") + public void transactionsExcludedByLoanExternalIdCheck(String excludedTypes, DataTable table) throws IOException { + Response loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + String loanExternalId = loanCreateResponse.body().getResourceExternalId(); + long loanId = loanCreateResponse.body().getLoanId(); + String resourceId = String.valueOf(loanId); + + Response transactionsByLoanExternalIdFiltered = getTransactionsByLoanIExternalIdFiltered( + loanExternalId, excludedTypes); + ErrorHelper.checkSuccessfulApiCall(transactionsByLoanExternalIdFiltered); + + List transactions = transactionsByLoanExternalIdFiltered.body().getContent(); + List> data = table.asLists(); + for (int i = 1; i < data.size(); i++) { + List expectedValues = data.get(i); + String transactionDateExpected = expectedValues.get(0); + List> actualValuesList = transactions.stream()// + .filter(t -> transactionDateExpected.equals(FORMATTER.format(t.getDate())))// + .map(t -> fetchValuesOfFilteredTransaction(table.row(0), t))// + .collect(Collectors.toList());// + boolean containsExpectedValues = actualValuesList.stream()// + .anyMatch(actualValues -> actualValues.equals(expectedValues));// + assertThat(containsExpectedValues) + .as(ErrorMessageHelper.wrongValueInLineInTransactionsTab(resourceId, i, actualValuesList, expectedValues)).isTrue(); + } + assertThat(transactions.size()) + .as(ErrorMessageHelper.nrOfLinesWrongInTransactionsTab(resourceId, transactions.size(), data.size() - 1)) + .isEqualTo(data.size() - 1); + } + + private Response getTransactionsByLoanIdFiltered(Long loanId, String excludedTypes) + throws IOException { + return loanTransactionsApi.retrieveTransactionsByLoanId(loanId, parseExcludedTypes(excludedTypes), null, null, null).execute(); + } + + private Response getTransactionsByLoanIExternalIdFiltered(String loanExternalId, + String excludedTypes) throws IOException { + + return loanTransactionsApi.retrieveTransactionsByExternalLoanId(loanExternalId, parseExcludedTypes(excludedTypes), null, null, null) + .execute(); + } + + public static List parseExcludedTypes(String excludedTypes) { + if (excludedTypes == null || excludedTypes.trim().isEmpty()) { + return Collections.emptyList(); + } else { + return Arrays.stream(excludedTypes.split(",")).map(String::trim).map(String::toUpperCase) + .map(org.apache.fineract.client.models.TransactionType::valueOf).collect(Collectors.toList()); + } + } + + private List fetchValuesOfFilteredTransaction(List header, GetLoansLoanIdTransactionsTransactionIdResponse t) { + List actualValues = new ArrayList<>(); + for (String headerName : header) { + switch (headerName) { + case "Transaction date" -> actualValues.add(t.getDate() == null ? null : FORMATTER.format(t.getDate())); + case "Transaction Type" -> actualValues.add(t.getType().getCode() == null ? null : t.getType().getCode().substring(20)); + case "Amount" -> actualValues.add(t.getAmount() == null ? null : String.valueOf(t.getAmount())); + case "Principal" -> actualValues.add(t.getPrincipalPortion() == null ? null : String.valueOf(t.getPrincipalPortion())); + case "Interest" -> actualValues.add(t.getInterestPortion() == null ? null : String.valueOf(t.getInterestPortion())); + case "Fees" -> actualValues.add(t.getFeeChargesPortion() == null ? null : String.valueOf(t.getFeeChargesPortion())); + case "Penalties" -> + actualValues.add(t.getPenaltyChargesPortion() == null ? null : String.valueOf(t.getPenaltyChargesPortion())); + case "Loan Balance" -> + actualValues.add(t.getOutstandingLoanBalance() == null ? null : String.valueOf(t.getOutstandingLoanBalance())); + case "Overpayment" -> + actualValues.add(t.getOverpaymentPortion() == null ? null : String.valueOf(t.getOverpaymentPortion())); + default -> throw new IllegalStateException(String.format("Header name %s cannot be found", headerName)); + } + } + return actualValues; + } + + @Then("Filtered out transactions list has {int} pages in case of size set to {int} and transactions are filtered out for transaction types: {string}") + public void checkPagination(Integer totalPagesExpected, Integer size, String excludedTypes) throws IOException { + Response loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanCreateResponse.body().getLoanId(); + + Response transactionsByLoanIdFiltered = loanTransactionsApi + .retrieveTransactionsByLoanId(loanId, parseExcludedTypes(excludedTypes), null, size, null).execute(); + + Integer totalPagesActual = transactionsByLoanIdFiltered.body().getTotalPages(); + + assertThat(totalPagesActual).as(ErrorMessageHelper.wrongValueInTotalPages(totalPagesActual, totalPagesExpected)) + .isEqualTo(totalPagesExpected); + } } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature index be06883c0..482d2666e 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature @@ -6038,6 +6038,7 @@ Feature: LoanAccrualActivity | 01 July 2024 | Repayment | 340.17 | 336.11 | 1.96 | 0.0 | 0.0 | 0.0 | false | false | | 01 July 2024 | Accrual Activity | 1.96 | 0.0 | 1.96 | 0.0 | 0.0 | 0.0 | false | false | + @TestRailId:C3525 Scenario: Verify accrual activity behavior in case of backdated repayment - UC1 When Admin sets the business date to "01 January 2025" @@ -6399,3 +6400,52 @@ Feature: LoanAccrualActivity | 01 February 2025 | Accrual Activity | 4.67 | 0.0 | 4.67 | 0.0 | 0.0 | 0.0 | false | true | And "Accrual Activity" transaction on "01 February 2025" got reverse-replayed on "02 February 2025" And External ID of replayed "Accrual Activity" on "01 February 2025" is matching with "saved-external-id" + + @TestRailId:C3533 + Scenario: Logging out transaction list, excluded given transaction types + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_RECOGNITION_DISBURSEMENT_DAILY_EMI_360_30_ACCRUAL_ACTIVITY | 01 January 2025 | 2000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "2000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "2000" EUR transaction amount + And Admin runs inline COB job for Loan + When Admin sets the business date to "05 January 2025" + And Customer makes "AUTOPAY" repayment on "05 January 2025" with 200 EUR transaction amount + And Admin runs inline COB job for Loan + And Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 2000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 2000.0 | false | false | + | 01 January 2025 | Accrual | 0.38 | 0.0 | 0.38 | 0.0 | 0.0 | 0.0 | false | false | + | 02 January 2025 | Accrual | 0.37 | 0.0 | 0.37 | 0.0 | 0.0 | 0.0 | false | false | + | 03 January 2025 | Accrual | 0.38 | 0.0 | 0.38 | 0.0 | 0.0 | 0.0 | false | false | + | 04 January 2025 | Accrual | 0.38 | 0.0 | 0.38 | 0.0 | 0.0 | 0.0 | false | false | + | 05 January 2025 | Repayment | 200.0 | 200.0 | 0.0 | 0.0 | 0.0 | 1800.0 | false | false | + Then Log out transaction list by loanId, filtered out the following transaction types: "disbursement, accrual" + Then Log out transaction list by loanExternalId, filtered out the following transaction types: "accrual" + Then Filtered out transactions list contains the the following entries when filtered out by loanId for transaction types: "accrual" + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | + | 01 January 2025 | disbursement | 2000.0 | | | | | 2000.0 | + | 05 January 2025 | repayment | 200.0 | 200.0 | | | | 1800.0 | + Then Filtered out transactions list contains the the following entries when filtered out by loanExternalId for transaction types: "accrual" + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | + | 01 January 2025 | disbursement | 2000.0 | | | | | 2000.0 | + | 05 January 2025 | repayment | 200.0 | 200.0 | | | | 1800.0 | + Then Filtered out transactions list contains the the following entries when filtered out by loanId for transaction types: "" + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | + | 01 January 2025 | disbursement | 2000.0 | | | | | 2000.0 | + | 01 January 2025 | accrual | 0.38 | | 0.38 | | | | + | 02 January 2025 | accrual | 0.37 | | 0.37 | | | | + | 03 January 2025 | accrual | 0.38 | | 0.38 | | | | + | 04 January 2025 | accrual | 0.38 | | 0.38 | | | | + | 05 January 2025 | repayment | 200.0 | 200.0 | | | | 1800.0 | + Then Filtered out transactions list contains the the following entries when filtered out by loanId for transaction types: "merchant_issued_refund" + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | + | 01 January 2025 | disbursement | 2000.0 | | | | | 2000.0 | + | 01 January 2025 | accrual | 0.38 | | 0.38 | | | | + | 02 January 2025 | accrual | 0.37 | | 0.37 | | | | + | 03 January 2025 | accrual | 0.38 | | 0.38 | | | | + | 04 January 2025 | accrual | 0.38 | | 0.38 | | | | + | 05 January 2025 | repayment | 200.0 | 200.0 | | | | 1800.0 | + Then Filtered out transactions list has 4 pages in case of size set to 1 and transactions are filtered out for transaction types: "disbursement, repayment" diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java new file mode 100644 index 000000000..affae3223 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java @@ -0,0 +1,56 @@ +/** + * 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.loanaccount.api; + +public interface LoanTransactionApiConstants { + + enum TransactionType { + disbursement, // + repayment, // + waiver, // + repaymentAtDisbursement, // + writeOff, // + markedForRescheduling, // + recoveryRepayment, // + waiveCharges, // + accrual, // + initiateTransfer, // + approveTransfer, // + withdrawTransfer, // + rejectTransfer, // + refund, // + chargePayment, // + incomePosting, // + creditBalanceRefund, // + merchantIssuedRefund, // + payoutRefund, // + goodwillCredit, // + chargeRefund, // + chargeback, // + chargeAdjustment, // + chargeOff, // + downPayment, // + reAge, // + reAmortize, // + interestPaymentWaiver, // + accrualActivity, // + interestRefund, // + accrualAdjustment, // + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResourceSwagger.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResourceSwagger.java index a938039a8..f9a17ed38 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResourceSwagger.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResourceSwagger.java @@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.loanaccount.api; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.Set; +import org.springframework.data.domain.Page; /** * Created by Chirag Gupta on 12/30/17. @@ -275,6 +276,12 @@ final class LoanTransactionsApiResourceSwagger { } } + @Schema(description = "GetLoansLoanIdTransactionsResponse") + public abstract static class GetLoansLoanIdTransactionsResponse implements Page { + + private GetLoansLoanIdTransactionsResponse() {} + } + @Schema(description = "PostLoansLoanIdTransactionsRequest") public static final class PostLoansLoanIdTransactionsRequest { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionData.java index 8942201bc..8c61f9e83 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionData.java @@ -29,6 +29,7 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.portfolio.account.data.AccountTransferData; import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; +import org.springframework.integration.annotation.Default; /** * Immutable data object representing a loan transaction. @@ -213,6 +214,7 @@ public class LoanTransactionData { } + @Default // Default constructor for mapper public LoanTransactionData(final Long id, final Long officeId, final String officeName, final LoanTransactionEnumData transactionType, final PaymentDetailData paymentDetailData, final CurrencyData currency, final LocalDate date, final BigDecimal amount, final BigDecimal netDisbursalAmount, final BigDecimal principalPortion, final BigDecimal interestPortion, diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepositoryWrapper.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepositoryWrapper.java index a0d926339..f8669b4cc 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepositoryWrapper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepositoryWrapper.java @@ -288,4 +288,8 @@ public class LoanRepositoryWrapper { public List findIdByExternalIds(List externalIds) { return repository.findIdsByExternalIds(externalIds); } + + public boolean existsByLoanId(Long loanId) { + return repository.existsById(loanId); + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index b8de87875..a83b9f884 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -44,17 +44,13 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.portfolio.account.data.AccountTransferData; -import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; -import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; /** @@ -724,19 +720,6 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom toMapData(final String currencyCode) { final Map thisTransactionData = new LinkedHashMap<>(); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTransactionMapper.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTransactionMapper.java new file mode 100644 index 000000000..101165eab --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTransactionMapper.java @@ -0,0 +1,51 @@ +/** + * 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.loanaccount.mapper; + +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(config = MapstructMapperConfig.class, uses = { LoanTransactionRelationMapper.class, LoanChargePaidByMapper.class }) +public interface LoanTransactionMapper { + + @Mapping(target = "numberOfRepayments", ignore = true) + @Mapping(target = "loanRepaymentScheduleInstallments", ignore = true) + @Mapping(target = "writeOffReasonOptions", ignore = true) + @Mapping(target = "chargeOffReasonOptions", ignore = true) + @Mapping(target = "paymentTypeOptions", ignore = true) + @Mapping(target = "overpaymentPortion", ignore = true) + @Mapping(target = "transfer", ignore = true) + @Mapping(target = "fixedEmiAmount", ignore = true) + @Mapping(target = "date", source = "dateOf") + @Mapping(target = "loanChargePaidByList", source = "loanChargesPaid") + @Mapping(target = "manuallyReversed", source = "manuallyAdjustedOrReversed") + @Mapping(target = "transactionRelations", source = "loanTransactionRelations") + @Mapping(target = "officeId", source = "office.id") + @Mapping(target = "officeName", source = "office.name") + @Mapping(target = "loanId", source = "loan.id") + @Mapping(target = "externalLoanId", source = "loan.externalId") + @Mapping(target = "netDisbursalAmount", source = "loan.netDisbursalAmount") + @Mapping(target = "transactionType", expression = "java(org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations.transactionType(loanTransaction.getTypeOf()))") + @Mapping(target = "paymentDetailData", expression = "java(loanTransaction.getPaymentDetail() != null ? loanTransaction.getPaymentDetail().toData() : null)") + @Mapping(target = "currency", expression = "java(loanTransaction.getLoan().getCurrency().toData())") + LoanTransactionData mapLoanTransaction(LoanTransaction loanTransaction); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyConfig.java index c5a18f0c0..d44737f2e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyConfig.java @@ -20,11 +20,15 @@ package org.apache.fineract.infrastructure.core.jersey; import jakarta.annotation.PostConstruct; +import jakarta.inject.Singleton; import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.Path; import jakarta.ws.rs.ext.Provider; +import org.apache.fineract.infrastructure.core.api.jersey.PageableParamProvider; +import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.ServerProperties; +import org.glassfish.jersey.server.spi.internal.ValueParamProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Configuration; @@ -35,6 +39,13 @@ public class JerseyConfig extends ResourceConfig { JerseyConfig() { register(org.glassfish.jersey.media.multipart.MultiPartFeature.class); + register(new AbstractBinder() { + + @Override + protected void configure() { + bind(PageableParamProvider.class).to(ValueParamProvider.class).in(Singleton.class); + } + }); property(ServerProperties.WADL_FEATURE_DISABLE, true); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java index 4aa3e544c..1a94ef27d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java @@ -45,12 +45,14 @@ import java.util.HashSet; import java.util.List; import java.util.Set; import lombok.AllArgsConstructor; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.api.DateParam; +import org.apache.fineract.infrastructure.core.api.jersey.Pagination; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.DateFormat; import org.apache.fineract.infrastructure.core.domain.ExternalId; @@ -64,12 +66,15 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte import org.apache.fineract.portfolio.loanaccount.data.LoanRepaymentScheduleInstallmentData; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException; import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; import org.apache.fineract.portfolio.loanaccount.service.LoanChargePaidByReadService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadPlatformService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @Path("/v1/loans") @@ -224,6 +229,40 @@ public class LoanTransactionsApiResource { return retrieveTransaction(null, loanExternalId, null, externalTransactionId, uriInfo); } + @GET + @Path("{loanId}/transactions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve Transactions", description = "Retrieves transactions of a loan") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.GetLoansLoanIdTransactionsResponse.class))) }) + public Page retrieveTransactionsByLoanId( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @QueryParam("excludedTypes") @Parameter(description = "excludedTypes", in = ParameterIn.QUERY, schema = @Schema(enumAsRef = true)) final List excludedTypes, + @QueryParam("page") @Parameter(description = "page") final Integer page, + @QueryParam("size") @Parameter(description = "size") final Integer size, + @QueryParam("sort") @Parameter(description = "sort") final String sort, @Parameter(hidden = true) @Pagination Pageable pageable, + @Context final UriInfo uriInfo) { + return retrieveTransactions(loanId, null, excludedTypes, pageable, uriInfo); + } + + @GET + @Path("external-id/{loanExternalId}/transactions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve Transactions", description = "Retrieves transactions of a loan") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.GetLoansLoanIdTransactionsResponse.class))) }) + public Page retrieveTransactionsByExternalLoanId( + @PathParam("loanExternalId") @Parameter(description = "loanId", required = true) final String loanExternalId, + @QueryParam("excludedTypes") @Parameter(description = "excludedTypes", in = ParameterIn.QUERY, schema = @Schema(enumAsRef = true)) final List excludedTypes, + @QueryParam("page") @Parameter(description = "page") final Integer page, + @QueryParam("size") @Parameter(description = "size") final Integer size, + @QueryParam("sort") @Parameter(description = "sort") final String sort, @Parameter(hidden = true) @Pagination Pageable pageable, + @Context final UriInfo uriInfo) { + return retrieveTransactions(null, loanExternalId, excludedTypes, pageable, uriInfo); + } + @POST @Path("{loanId}/transactions") @Consumes({ MediaType.APPLICATION_JSON }) @@ -439,6 +478,59 @@ public class LoanTransactionsApiResource { return this.toApiJsonSerializer.serialize(settings, transactionData, this.responseDataParameters); } + private Page retrieveTransactions(final Long loanId, final String loanExternalIdStr, + final List excludedTypes, Pageable pageable, final UriInfo uriInfo) { + + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + ExternalId loanExternalId = ExternalIdFactory.produce(loanExternalIdStr); + Long resolvedLoanId = getResolvedLoanIdWithExistsCheck(loanId, loanExternalId); + + Set excludedTransactionTypes = new HashSet<>(); + if (CollectionUtils.isNotEmpty(excludedTypes)) { + excludedTypes.forEach(x -> excludedTransactionTypes.add(transactionTypeFromParam(x))); + } + + return loanReadPlatformService.retrieveLoanTransactions(resolvedLoanId, excludedTransactionTypes, pageable); + } + + private LoanTransactionType transactionTypeFromParam(LoanTransactionApiConstants.TransactionType transactionTypeParam) { + return switch (transactionTypeParam) { + case disbursement -> LoanTransactionType.DISBURSEMENT; + case repayment -> LoanTransactionType.REPAYMENT; + case waiver -> LoanTransactionType.WAIVE_INTEREST; + case repaymentAtDisbursement -> LoanTransactionType.REPAYMENT_AT_DISBURSEMENT; + case writeOff -> LoanTransactionType.WRITEOFF; + case markedForRescheduling -> LoanTransactionType.MARKED_FOR_RESCHEDULING; + case recoveryRepayment -> LoanTransactionType.RECOVERY_REPAYMENT; + case waiveCharges -> LoanTransactionType.WAIVE_CHARGES; + case accrual -> LoanTransactionType.ACCRUAL; + case initiateTransfer -> LoanTransactionType.INITIATE_TRANSFER; + case approveTransfer -> LoanTransactionType.APPROVE_TRANSFER; + case withdrawTransfer -> LoanTransactionType.WITHDRAW_TRANSFER; + case rejectTransfer -> LoanTransactionType.REJECT_TRANSFER; + case refund -> LoanTransactionType.REFUND; + case chargePayment -> LoanTransactionType.CHARGE_PAYMENT; + case incomePosting -> LoanTransactionType.INCOME_POSTING; + case creditBalanceRefund -> LoanTransactionType.CREDIT_BALANCE_REFUND; + case merchantIssuedRefund -> LoanTransactionType.MERCHANT_ISSUED_REFUND; + case payoutRefund -> LoanTransactionType.PAYOUT_REFUND; + case goodwillCredit -> LoanTransactionType.GOODWILL_CREDIT; + case chargeRefund -> LoanTransactionType.CHARGE_REFUND; + case chargeback -> LoanTransactionType.CHARGEBACK; + case chargeAdjustment -> LoanTransactionType.CHARGE_ADJUSTMENT; + case chargeOff -> LoanTransactionType.CHARGE_OFF; + case reAge -> LoanTransactionType.REAGE; + case reAmortize -> LoanTransactionType.REAMORTIZE; + case interestPaymentWaiver -> LoanTransactionType.INTEREST_PAYMENT_WAIVER; + case accrualActivity -> LoanTransactionType.ACCRUAL_ACTIVITY; + case interestRefund -> LoanTransactionType.INTEREST_REFUND; + case accrualAdjustment -> LoanTransactionType.ACCRUAL_ADJUSTMENT; + default -> + throw new InvalidLoanTransactionTypeException("transaction", transactionTypeParam.name(), "Unknown transaction type"); + }; + } + private String executeTransaction(final Long loanId, final String loanExternalIdStr, final String commandParam, final String apiRequestBodyAsJson) { final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson); @@ -637,4 +729,21 @@ public class LoanTransactionsApiResource { } return resolvedLoanId; } + + private Long getResolvedLoanIdWithExistsCheck(final Long loanId, final ExternalId loanExternalId) { + if (loanId != null) { + if (!loanReadPlatformService.existsByLoanId(loanId)) { + throw new LoanNotFoundException(loanId); + } + return loanId; + } else if (loanExternalId != null) { + final Long resolvedLoanId = loanReadPlatformService.retrieveLoanIdByExternalId(loanExternalId); + if (resolvedLoanId == null) { + throw new LoanNotFoundException(loanExternalId); + } + return resolvedLoanId; + } else { + throw new IllegalArgumentException("loanId and loanExternalId cannot be both null"); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java index 21abb32fd..02eddf327 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java @@ -18,9 +18,11 @@ */ package org.apache.fineract.portfolio.loanaccount.service; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.util.Collection; import java.util.List; +import java.util.Set; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.SearchParameters; @@ -39,6 +41,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; +import org.springframework.data.domain.Pageable; public interface LoanReadPlatformService { @@ -51,6 +54,9 @@ public interface LoanReadPlatformService { Collection retrieveLoanTransactions(Long loanId); + org.springframework.data.domain.Page retrieveLoanTransactions(@NotNull Long loanId, + Set excludedTransactionTypes, Pageable pageable); + LoanAccountData retrieveTemplateWithClientAndProductDetails(Long clientId, Long productId); LoanAccountData retrieveTemplateWithGroupAndProductDetails(Long groupId, Long productId); @@ -143,4 +149,6 @@ public interface LoanReadPlatformService { Long retrieveLoanIdByExternalId(ExternalId externalId); List retrieveLoanIdsByExternalIds(List externalIds); + + boolean existsByLoanId(Long loanId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java index a985a74bc..9ab53d26e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java @@ -21,6 +21,8 @@ package org.apache.fineract.portfolio.loanaccount.service; import static java.lang.Boolean.TRUE; import static org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations.interestType; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; @@ -120,6 +122,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleP import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; +import org.apache.fineract.portfolio.loanaccount.mapper.LoanTransactionMapper; import org.apache.fineract.portfolio.loanaccount.serialization.LoanForeclosureValidator; import org.apache.fineract.portfolio.loanproduct.data.LoanProductData; import org.apache.fineract.portfolio.loanproduct.data.TransactionProcessingStrategyData; @@ -134,6 +137,7 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.jetbrains.annotations.NotNull; import org.springframework.dao.DataAccessException; import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.data.domain.Pageable; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; @@ -174,6 +178,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa private final LoanChargePaidByReadService loanChargePaidByReadService; private final LoanTransactionRelationReadService loanTransactionRelationReadService; private final LoanForeclosureValidator loanForeclosureValidator; + private final LoanTransactionMapper loanTransactionMapper; @Override public LoanAccountData retrieveOne(final Long loanId) { @@ -598,6 +603,27 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa } } + @Override + public org.springframework.data.domain.Page retrieveLoanTransactions( + @jakarta.validation.constraints.NotNull Long loanId, Set excludedTransactionTypes, Pageable pageable) { + final org.springframework.data.domain.Page transactionPage = loanTransactionRepository + .findAll((root, query, builder) -> { + List predicates = new ArrayList<>(); + + final Join loanjoin = root.join("loan"); + predicates.add(builder.equal(loanjoin.get("id"), loanId)); + + if (excludedTransactionTypes != null && !excludedTransactionTypes.isEmpty()) { + final List excludedTransactionTypeValues = excludedTransactionTypes.stream() + .map(LoanTransactionType::getValue).toList(); + predicates.add(builder.not(root.get("typeOf").in(excludedTransactionTypeValues))); + } + return builder.and(predicates.toArray(new Predicate[] {})); + }, pageable); + + return transactionPage.map(loanTransactionMapper::mapLoanTransaction); + } + @Override public Long getLoanIdByLoanExternalId(String externalId) { ExternalId loanExternalId = ExternalIdFactory.produce(externalId); @@ -2182,4 +2208,8 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa return loanRepositoryWrapper.findIdByExternalIds(externalIds); } + @Override + public boolean existsByLoanId(Long loanId) { + return loanRepositoryWrapper.existsByLoanId(loanId); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index b4aa6029a..120a13574 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -85,6 +85,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.service.LoanSchedu import org.apache.fineract.portfolio.loanaccount.loanschedule.service.LoanScheduleHistoryWritePlatformService; import org.apache.fineract.portfolio.loanaccount.mapper.LoanChargeMapper; import org.apache.fineract.portfolio.loanaccount.mapper.LoanCollateralManagementMapper; +import org.apache.fineract.portfolio.loanaccount.mapper.LoanTransactionMapper; import org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanTermVariationsRepository; import org.apache.fineract.portfolio.loanaccount.serialization.LoanApplicationTransitionValidator; import org.apache.fineract.portfolio.loanaccount.serialization.LoanApplicationValidator; @@ -325,7 +326,7 @@ public class LoanAccountConfiguration { ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, DelinquencyReadPlatformService delinquencyReadPlatformService, LoanTransactionRepository loanTransactionRepository, LoanChargePaidByReadService loanChargePaidByReadService, LoanTransactionRelationReadService loanTransactionRelationReadService, - LoanForeclosureValidator loanForeclosureValidator) { + LoanForeclosureValidator loanForeclosureValidator, LoanTransactionMapper loanTransactionMapper) { return new LoanReadPlatformServiceImpl(jdbcTemplate, context, loanRepositoryWrapper, applicationCurrencyRepository, loanProductReadPlatformService, clientReadPlatformService, groupReadPlatformService, loanDropdownReadPlatformService, fundReadPlatformService, chargeReadPlatformService, codeValueReadPlatformService, calendarReadPlatformService, @@ -333,7 +334,7 @@ public class LoanAccountConfiguration { loanRepaymentScheduleTransactionProcessorFactory, floatingRatesReadPlatformService, loanUtilService, configurationDomainService, accountDetailsReadPlatformService, columnValidator, sqlGenerator, delinquencyReadPlatformService, loanTransactionRepository, loanChargePaidByReadService, loanTransactionRelationReadService, - loanForeclosureValidator); + loanForeclosureValidator, loanTransactionMapper); } @Bean diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionTest.java new file mode 100644 index 000000000..4500bb14b --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionTest.java @@ -0,0 +1,89 @@ +/** + * 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; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.fineract.client.models.GetLoansLoanIdTransactionsResponse; +import org.apache.fineract.client.models.PostClientsResponse; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.TransactionType; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LoanTransactionTest extends BaseLoanIntegrationTest { + + @Test + public void testGetLoanTransactionsFiltering() { + final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + + final AtomicReference loanIdRef = new AtomicReference<>(); + + final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive()); + + final String loanExternalIdStr = UUID.randomUUID().toString(); + + runAt("20 December 2024", () -> { + Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "20 December 2024", + 430.0, 7.0, 6, (request) -> request.externalId(loanExternalIdStr)); + + loanIdRef.set(loanId); + + disburseLoan(loanId, BigDecimal.valueOf(430), "20 December 2024"); + }); + runAt("21 December 2024", () -> { + executeInlineCOB(loanIdRef.get()); + }); + runAt("20 January 2025", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "20 January 2025", 82.20); + }); + runAt("20 February 2025", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "20 February 2025", 82.20); + }); + runAt("23 February 2025", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + final GetLoansLoanIdTransactionsResponse allLoanTransactionsPage = loanTransactionHelper.getLoanTransactions(loanId); + Assertions.assertEquals(67L, allLoanTransactionsPage.getTotalElements()); + + final GetLoansLoanIdTransactionsResponse nonAccrualLoanTransactionsPage = loanTransactionHelper.getLoanTransactions(loanId, + List.of(TransactionType.ACCRUAL)); + Assertions.assertEquals(3L, nonAccrualLoanTransactionsPage.getTotalElements()); + + final GetLoansLoanIdTransactionsResponse allLoanTransactionsByExternalIdPage = loanTransactionHelper + .getLoanTransactionsByExternalId(loanExternalIdStr); + Assertions.assertEquals(67L, allLoanTransactionsByExternalIdPage.getTotalElements()); + + final GetLoansLoanIdTransactionsResponse nonAccrualLoanTransactionsByExternalIdPage = loanTransactionHelper + .getLoanTransactionsByExternalId(loanExternalIdStr, List.of(TransactionType.ACCRUAL)); + Assertions.assertEquals(3L, nonAccrualLoanTransactionsByExternalIdPage.getTotalElements()); + }); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java index 84bc789c2..91b9ff3b1 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java @@ -38,6 +38,7 @@ import java.lang.reflect.Type; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -60,6 +61,7 @@ import org.apache.fineract.client.models.GetLoansLoanIdRepaymentSchedule; import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdSummary; import org.apache.fineract.client.models.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.GetLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse; import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTransactionIdResponse; import org.apache.fineract.client.models.PaymentTypeData; @@ -86,6 +88,7 @@ import org.apache.fineract.client.models.PutLoansLoanIdChargesChargeIdRequest; import org.apache.fineract.client.models.PutLoansLoanIdChargesChargeIdResponse; import org.apache.fineract.client.models.PutLoansLoanIdRequest; import org.apache.fineract.client.models.PutLoansLoanIdResponse; +import org.apache.fineract.client.models.TransactionType; import org.apache.fineract.client.util.CallFailedRuntimeException; import org.apache.fineract.client.util.Calls; import org.apache.fineract.client.util.JSON; @@ -1575,6 +1578,35 @@ public class LoanTransactionHelper { return Calls.ok(FineractClientHelper.getFineractClient().loans.retrieveLoan1(loanExternalId, false, "all", null, null)); } + public GetLoansLoanIdTransactionsResponse getLoanTransactions(final Long loanId) { + return getLoanTransactions(loanId, Collections.emptyList(), null, null, null); + } + + public GetLoansLoanIdTransactionsResponse getLoanTransactions(final Long loanId, List excludedTransactionTypes) { + return getLoanTransactions(loanId, excludedTransactionTypes, null, null, null); + } + + public GetLoansLoanIdTransactionsResponse getLoanTransactions(final Long loanId, List excludedTransactionTypes, + Integer page, Integer size, String sort) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.retrieveTransactionsByLoanId(loanId, + excludedTransactionTypes, page, size, sort)); + } + + public GetLoansLoanIdTransactionsResponse getLoanTransactionsByExternalId(final String loanExternalId) { + return getLoanTransactionsByExternalId(loanExternalId, Collections.emptyList(), null, null, null); + } + + public GetLoansLoanIdTransactionsResponse getLoanTransactionsByExternalId(final String loanExternalId, + List excludedTransactionTypes) { + return getLoanTransactionsByExternalId(loanExternalId, excludedTransactionTypes, null, null, null); + } + + public GetLoansLoanIdTransactionsResponse getLoanTransactionsByExternalId(final String loanExternalId, + List excludedTransactionTypes, Integer page, Integer size, String sort) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.retrieveTransactionsByExternalLoanId(loanExternalId, + excludedTransactionTypes, page, size, sort)); + } + // TODO: Rewrite to use fineract-client instead! // Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long, // org.apache.fineract.client.models.PostLoansLoanIdRequest)