diff --git a/fineract-client/src/main/java/org/apache/fineract/client/util/FineractClient.java b/fineract-client/src/main/java/org/apache/fineract/client/util/FineractClient.java index ec6aa161b..2237d8ec8 100644 --- a/fineract-client/src/main/java/org/apache/fineract/client/util/FineractClient.java +++ b/fineract-client/src/main/java/org/apache/fineract/client/util/FineractClient.java @@ -93,6 +93,7 @@ import org.apache.fineract.client.services.LoanProductsApi; import org.apache.fineract.client.services.LoanReschedulingApi; import org.apache.fineract.client.services.LoanTransactionsApi; import org.apache.fineract.client.services.LoansApi; +import org.apache.fineract.client.services.LoansPointInTimeApi; import org.apache.fineract.client.services.MakerCheckerOr4EyeFunctionalityApi; import org.apache.fineract.client.services.MappingFinancialActivitiesToAccountsApi; import org.apache.fineract.client.services.MixMappingApi; @@ -229,6 +230,7 @@ public final class FineractClient { public final LoanCollateralApi loanCollaterals; public final LoanProductsApi loanProducts; public final LoanReschedulingApi loanSchedules; + public final LoansPointInTimeApi loansPointInTimeApi; public final LoansApi loans; public final LoanDisbursementDetailsApi loanDisbursementDetails; public final LoanTransactionsApi loanTransactions; @@ -355,6 +357,7 @@ public final class FineractClient { loanCollaterals = retrofit.create(LoanCollateralApi.class); loanProducts = retrofit.create(LoanProductsApi.class); loanSchedules = retrofit.create(LoanReschedulingApi.class); + loansPointInTimeApi = retrofit.create(LoansPointInTimeApi.class); loans = retrofit.create(LoansApi.class); loanDisbursementDetails = retrofit.create(LoanDisbursementDetailsApi.class); loanTransactions = retrofit.create(LoanTransactionsApi.class); diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java index 4a6bc7050..13ab2a2ea 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java @@ -30,6 +30,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import net.fortuna.ical4j.model.property.RRule; import net.fortuna.ical4j.validate.ValidationException; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -616,6 +617,21 @@ public class DataValidatorBuilder { return this; } + public DataValidatorBuilder listNotEmpty() { + if (this.value == null && this.ignoreNullValue) { + return this; + } + + final List list = (List) this.value; + if (CollectionUtils.isEmpty(list)) { + String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + ".cannot.be.empty"; + String defaultEnglishMessage = "The parameter `" + this.parameter + "` cannot be empty. You must select at least one."; + final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultEnglishMessage, this.parameter); + this.dataValidationErrors.add(error); + } + return this; + } + public DataValidatorBuilder jsonArrayNotEmpty() { if (this.value == null && this.ignoreNullValue) { return this; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/ExternalIdFactory.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/ExternalIdFactory.java index 35c8dcd9a..4c140ad75 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/ExternalIdFactory.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/ExternalIdFactory.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.infrastructure.core.service; +import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; @@ -35,6 +37,11 @@ public class ExternalIdFactory { return StringUtils.isBlank(value) ? ExternalId.empty() : new ExternalId(value); } + public static List produce(List values) { + Objects.requireNonNull(values, "values must not be null"); + return values.stream().map(ExternalIdFactory::produce).toList(); + } + public ExternalId createFromCommand(JsonCommand command, final String externalIdKey) { String externalIdStr = null; if (command.parsedJson() != null) { diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/data/CurrencyData.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/data/CurrencyData.java index 2e15a6d3b..102cf3d16 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/data/CurrencyData.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/data/CurrencyData.java @@ -21,6 +21,8 @@ package org.apache.fineract.organisation.monetary.data; import java.io.Serializable; import lombok.EqualsAndHashCode; import lombok.Getter; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; /** * Immutable data object representing currency. @@ -87,4 +89,12 @@ public class CurrencyData implements Serializable { return builder.toString(); } + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class) + public interface Mapper { + + default CurrencyData map(MonetaryCurrency source) { + return source.toData(); + } + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanStatusEnumData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanStatusEnumData.java index e2950c835..04ff5c933 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanStatusEnumData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanStatusEnumData.java @@ -19,6 +19,9 @@ package org.apache.fineract.portfolio.loanaccount.data; import lombok.Getter; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; /** * Immutable data object represent loan status enumerations. @@ -51,4 +54,12 @@ public class LoanStatusEnumData { this.closed = this.closedObligationsMet || this.closedWrittenOff || this.closedRescheduled; this.overpaid = Long.valueOf(700).equals(this.id); } + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class) + public interface Mapper { + + default LoanStatusEnumData map(Loan source) { + return LoanEnumerations.status(source.getPlainStatus()); + } + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index 10f0ba6e6..62177e5b3 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -2693,11 +2693,15 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { return changedTransactionDetail; } + /* + * Probably this is buggy when a chargeback transaction happens + */ public void processPostDisbursementTransactions() { final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = getTransactionProcessor(); final List allNonContraTransactionsPostDisbursement = retrieveListOfTransactionsForReprocessing(); final List copyTransactions = new ArrayList<>(); if (!allNonContraTransactionsPostDisbursement.isEmpty()) { + // TODO: Probably this is not needed and can be eliminated, make sure to double check it for (LoanTransaction loanTransaction : allNonContraTransactionsPostDisbursement) { copyTransactions.add(LoanTransaction.copyTransactionProperties(loanTransaction)); } @@ -3507,6 +3511,10 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { return Optional.ofNullable(this.charges).orElse(new HashSet<>()); } + public void removeCharges(Predicate predicate) { + charges.removeIf(predicate); + } + public boolean hasDelinquencyBucket() { return (getLoanProduct().getDelinquencyBucket() != null); } @@ -3537,6 +3545,10 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { return getLoanTransactions().stream().filter(predicate).findFirst().orElse(null); } + public void removeLoanTransactions(Predicate predicate) { + loanTransactions.removeIf(predicate); + } + public LoanTransaction findChargedOffTransaction() { return getLoanTransaction(e -> e.isNotReversed() && e.isChargeOff()); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java index 4db8dedf8..b022596b8 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java @@ -85,6 +85,8 @@ public interface LoanRepository extends JpaRepository, JpaSpecificat String FIND_ID_BY_EXTERNAL_ID = "SELECT loan.id FROM Loan loan WHERE loan.externalId = :externalId"; + String FIND_IDS_BY_EXTERNAL_IDS = "SELECT loan.id FROM Loan loan WHERE loan.externalId IN :externalIds ORDER BY loan.id ASC"; + // should follow the logic of `FIND_ALL_NON_CLOSED_LOANS_BY_LAST_CLOSED_BUSINESS_DATE` query String FIND_OLDEST_COB_PROCESSED_LOAN = "select loan.id, loan.lastClosedBusinessDate from Loan loan where loan.loanStatus in (100,200,300,303,304) and loan.lastClosedBusinessDate = (select min(l.lastClosedBusinessDate) from Loan l where l" + ".loanStatus in (100,200,300,303,304) and l.lastClosedBusinessDate < :cobBusinessDate)"; @@ -217,6 +219,9 @@ public interface LoanRepository extends JpaRepository, JpaSpecificat @Query(FIND_ID_BY_EXTERNAL_ID) Long findIdByExternalId(@Param("externalId") ExternalId externalId); + @Query(FIND_IDS_BY_EXTERNAL_IDS) + List findIdsByExternalIds(@Param("externalIds") List externalIds); + @Query(FIND_ALL_NON_CLOSED_LOANS_BEHIND_BY_LOAN_IDS) List findAllNonClosedLoansBehindByLoanIds(@Param("cobBusinessDate") LocalDate cobBusinessDate, @Param("loanIds") List loanIds); 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 bac7cb5b4..a0d926339 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 @@ -285,4 +285,7 @@ public class LoanRepositoryWrapper { return repository.findLoansForAddAccrual(accountingType, tillDate, futureCharges); } + public List findIdByExternalIds(List externalIds) { + return repository.findIdsByExternalIds(externalIds); + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanScheduleService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanScheduleService.java index 84ed5d0ef..c936e5308 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanScheduleService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanScheduleService.java @@ -53,7 +53,7 @@ public class LoanScheduleService { } } - public void recalculateScheduleFromLastTransaction(final Loan loan, final ScheduleGeneratorDTO generatorDTO) { + public void recalculateSchedule(final Loan loan, final ScheduleGeneratorDTO generatorDTO) { if (loan.isInterestBearingAndInterestRecalculationEnabled() && !loan.isChargedOff()) { regenerateRepaymentScheduleWithInterestRecalculation(loan, generatorDTO); } else { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApi.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApi.java new file mode 100644 index 000000000..15107cc6d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApi.java @@ -0,0 +1,36 @@ +/** + * 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.pointintime; + +import java.util.List; +import org.apache.fineract.infrastructure.core.api.DateParam; +import org.apache.fineract.portfolio.loanaccount.api.pointintime.data.RetrieveLoansPointInTimeExternalIdsRequest; +import org.apache.fineract.portfolio.loanaccount.api.pointintime.data.RetrieveLoansPointInTimeRequest; +import org.apache.fineract.portfolio.loanaccount.data.LoanPointInTimeData; + +public interface LoansPointInTimeApi { + + LoanPointInTimeData retrieveLoanPointInTime(Long loanId, DateParam dateParam, String dateFormat, String locale); + + LoanPointInTimeData retrieveLoanPointInTimeByExternalId(String loanExternalId, DateParam dateParam, String dateFormat, String locale); + + List retrieveLoansPointInTime(RetrieveLoansPointInTimeRequest request); + + List retrieveLoansPointInTimeByExternalIds(RetrieveLoansPointInTimeExternalIdsRequest request); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApiDelegate.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApiDelegate.java new file mode 100644 index 000000000..0eb3616bd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApiDelegate.java @@ -0,0 +1,114 @@ +/** + * 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.pointintime; + +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.api.DateParam; +import org.apache.fineract.infrastructure.core.data.DateFormat; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.loanaccount.api.pointintime.data.RetrieveLoansPointInTimeExternalIdsRequest; +import org.apache.fineract.portfolio.loanaccount.api.pointintime.data.RetrieveLoansPointInTimeRequest; +import org.apache.fineract.portfolio.loanaccount.data.LoanPointInTimeData; +import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException; +import org.apache.fineract.portfolio.loanaccount.service.LoanPointInTimeService; +import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoansPointInTimeApiDelegate implements LoansPointInTimeApi { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "LOAN"; + + private final LoanPointInTimeService loanPointInTimeService; + private final LoanReadPlatformService loanReadPlatformService; + private final PlatformSecurityContext context; + + @Override + public LoanPointInTimeData retrieveLoanPointInTime(Long loanId, DateParam dateParam, String dateFormat, String locale) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return getLoanPointInTime(loanId, dateParam, dateFormat, locale); + } + + @Override + public LoanPointInTimeData retrieveLoanPointInTimeByExternalId(String loanExternalIdStr, DateParam dateParam, String dateFormat, + String locale) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + ExternalId loanExternalId = ExternalIdFactory.produce(loanExternalIdStr); + Long loanId = resolveExternalId(loanExternalId); + + return getLoanPointInTime(loanId, dateParam, dateFormat, locale); + } + + @Override + public List retrieveLoansPointInTime(RetrieveLoansPointInTimeRequest request) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + List loanIds = request.getLoanIds(); + DateParam dateParam = request.getDate(); + String dateFormat = request.getDateFormat(); + String locale = request.getLocale(); + + return getLoansPointInTime(loanIds, dateParam, dateFormat, locale); + } + + @Override + public List retrieveLoansPointInTimeByExternalIds(RetrieveLoansPointInTimeExternalIdsRequest request) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + List loanExternalIds = request.getExternalIds(); + DateParam dateParam = request.getDate(); + String dateFormat = request.getDateFormat(); + String locale = request.getLocale(); + + List externalIds = ExternalIdFactory.produce(loanExternalIds); + List loanIds = resolveExternalIds(externalIds); + + return getLoansPointInTime(loanIds, dateParam, dateFormat, locale); + } + + private List getLoansPointInTime(List loanIds, DateParam dateParam, String dateFormat, String locale) { + DateFormat df = StringUtils.isBlank(dateFormat) ? null : new DateFormat(dateFormat); + LocalDate date = dateParam.getDate("date", df, locale); + return loanPointInTimeService.retrieveAt(loanIds, date); + } + + private LoanPointInTimeData getLoanPointInTime(Long loanId, DateParam dateParam, String dateFormat, String locale) { + DateFormat df = StringUtils.isBlank(dateFormat) ? null : new DateFormat(dateFormat); + LocalDate date = dateParam.getDate("date", df, locale); + return loanPointInTimeService.retrieveAt(loanId, date); + } + + private List resolveExternalIds(List loanExternalIds) { + loanExternalIds.forEach(ExternalId::throwExceptionIfEmpty); + return loanReadPlatformService.retrieveLoanIdsByExternalIds(loanExternalIds); + } + + private Long resolveExternalId(ExternalId loanExternalId) { + loanExternalId.throwExceptionIfEmpty(); + Long resolvedLoanId = loanReadPlatformService.retrieveLoanIdByExternalId(loanExternalId); + if (resolvedLoanId == null) { + throw new LoanNotFoundException(loanExternalId); + } + return resolvedLoanId; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApiResource.java new file mode 100644 index 000000000..cb98db183 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/LoansPointInTimeApiResource.java @@ -0,0 +1,90 @@ +/** + * 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.pointintime; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.DateParam; +import org.apache.fineract.portfolio.loanaccount.api.pointintime.data.RetrieveLoansPointInTimeExternalIdsRequest; +import org.apache.fineract.portfolio.loanaccount.api.pointintime.data.RetrieveLoansPointInTimeRequest; +import org.apache.fineract.portfolio.loanaccount.data.LoanPointInTimeData; +import org.springframework.stereotype.Component; + +@Path("/v1/loans/at-date") +@Component +@Tag(name = "LoansPointInTime", description = "API to enable clients to retrieve Loan states in a specific point in time") +@RequiredArgsConstructor +public class LoansPointInTimeApiResource implements LoansPointInTimeApi { + + private final LoansPointInTimeApiDelegate delegate; + + @GET + @Path("{loanId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Override + public LoanPointInTimeData retrieveLoanPointInTime(@PathParam("loanId") @Parameter(description = "loanId", required = true) Long loanId, + @QueryParam("date") @Parameter(description = "date", required = true) DateParam dateParam, + @QueryParam("dateFormat") @Parameter(description = "dateFormat") final String dateFormat, + @QueryParam("locale") @Parameter(description = "locale") final String locale) { + return delegate.retrieveLoanPointInTime(loanId, dateParam, dateFormat, locale); + } + + @GET + @Path("external-id/{loanExternalId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Override + public LoanPointInTimeData retrieveLoanPointInTimeByExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) String loanExternalIdStr, + @QueryParam("date") @Parameter(description = "date", required = true) DateParam dateParam, + @QueryParam("dateFormat") @Parameter(description = "dateFormat") final String dateFormat, + @QueryParam("locale") @Parameter(description = "locale") final String locale) { + return delegate.retrieveLoanPointInTimeByExternalId(loanExternalIdStr, dateParam, dateFormat, locale); + } + + @POST + @Path("search") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Override + public List retrieveLoansPointInTime(@Parameter RetrieveLoansPointInTimeRequest request) { + return delegate.retrieveLoansPointInTime(request); + } + + @POST + @Path("search/external-id") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Override + public List retrieveLoansPointInTimeByExternalIds(@Parameter RetrieveLoansPointInTimeExternalIdsRequest request) { + return delegate.retrieveLoansPointInTimeByExternalIds(request); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/data/RetrieveLoansPointInTimeExternalIdsRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/data/RetrieveLoansPointInTimeExternalIdsRequest.java new file mode 100644 index 000000000..54d54c16e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/data/RetrieveLoansPointInTimeExternalIdsRequest.java @@ -0,0 +1,32 @@ +/** + * 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.pointintime.data; + +import java.util.List; +import lombok.Data; +import org.apache.fineract.infrastructure.core.api.DateParam; + +@Data +public class RetrieveLoansPointInTimeExternalIdsRequest { + + private List externalIds; + private DateParam date; + private String dateFormat; + private String locale; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/data/RetrieveLoansPointInTimeRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/data/RetrieveLoansPointInTimeRequest.java new file mode 100644 index 000000000..1586bec75 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/pointintime/data/RetrieveLoansPointInTimeRequest.java @@ -0,0 +1,32 @@ +/** + * 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.pointintime.data; + +import java.util.List; +import lombok.Data; +import org.apache.fineract.infrastructure.core.api.DateParam; + +@Data +public class RetrieveLoansPointInTimeRequest { + + private List loanIds; + private DateParam date; + private String dateFormat; + private String locale; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanFeeData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanFeeData.java new file mode 100644 index 000000000..a54ea209a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanFeeData.java @@ -0,0 +1,50 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import lombok.Data; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.mapstruct.Mapping; + +@Data +public class LoanFeeData { + + private final BigDecimal feeChargesCharged; + private final BigDecimal feeAdjustments; + private final BigDecimal feeChargesDueAtDisbursementCharged; + private final BigDecimal feeChargesPaid; + private final BigDecimal feeChargesWaived; + private final BigDecimal feeChargesWrittenOff; + private final BigDecimal feeChargesOutstanding; + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class) + public interface Mapper { + + @Mapping(source = "totalFeeChargesCharged", target = "feeChargesCharged") + @Mapping(source = "totalFeeChargesDueAtDisbursement", target = "feeChargesDueAtDisbursementCharged") + @Mapping(source = "totalFeeAdjustments", target = "feeAdjustments") + @Mapping(source = "totalFeeChargesRepaid", target = "feeChargesPaid") + @Mapping(source = "totalFeeChargesWaived", target = "feeChargesWaived") + @Mapping(source = "totalFeeChargesWrittenOff", target = "feeChargesWrittenOff") + @Mapping(source = "totalFeeChargesOutstanding", target = "feeChargesOutstanding") + LoanFeeData map(LoanSummary source); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanInterestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanInterestData.java new file mode 100644 index 000000000..1021cc5fa --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanInterestData.java @@ -0,0 +1,46 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import lombok.Data; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.mapstruct.Mapping; + +@Data +public class LoanInterestData { + + private final BigDecimal interestCharged; + private final BigDecimal interestPaid; + private final BigDecimal interestWaived; + private final BigDecimal interestWrittenOff; + private final BigDecimal interestOutstanding; + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class) + public interface Mapper { + + @Mapping(source = "totalInterestCharged", target = "interestCharged") + @Mapping(source = "totalInterestRepaid", target = "interestPaid") + @Mapping(source = "totalInterestWaived", target = "interestWaived") + @Mapping(source = "totalInterestWrittenOff", target = "interestWrittenOff") + @Mapping(source = "totalInterestOutstanding", target = "interestOutstanding") + LoanInterestData map(LoanSummary source); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPenaltyData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPenaltyData.java new file mode 100644 index 000000000..07261b7ca --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPenaltyData.java @@ -0,0 +1,48 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import lombok.Data; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.mapstruct.Mapping; + +@Data +public class LoanPenaltyData { + + private final BigDecimal penaltyChargesCharged; + private final BigDecimal penaltyAdjustments; + private final BigDecimal penaltyChargesPaid; + private final BigDecimal penaltyChargesWaived; + private final BigDecimal penaltyChargesWrittenOff; + private final BigDecimal penaltyChargesOutstanding; + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class) + public interface Mapper { + + @Mapping(source = "totalPenaltyChargesCharged", target = "penaltyChargesCharged") + @Mapping(source = "totalPenaltyAdjustments", target = "penaltyAdjustments") + @Mapping(source = "totalPenaltyChargesRepaid", target = "penaltyChargesPaid") + @Mapping(source = "totalPenaltyChargesWaived", target = "penaltyChargesWaived") + @Mapping(source = "totalPenaltyChargesWrittenOff", target = "penaltyChargesWrittenOff") + @Mapping(source = "totalPenaltyChargesOutstanding", target = "penaltyChargesOutstanding") + LoanPenaltyData map(LoanSummary source); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPointInTimeData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPointInTimeData.java new file mode 100644 index 000000000..685ef6c00 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPointInTimeData.java @@ -0,0 +1,74 @@ +/** + * 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.data; + +import lombok.Data; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.mapstruct.Mapping; + +@Data +public class LoanPointInTimeData { + + // Loan attributes + private Long id; + private String accountNo; + private LoanStatusEnumData status; + private String externalId; + private CurrencyData currency; + private LoanPrincipalData principal; + private LoanInterestData interest; + private LoanFeeData fee; + private LoanPenaltyData penalty; + private LoanTotalAmountData total; + + // Client attributes + private Long clientId; + private String clientAccountNo; + private String clientExternalId; + private String clientDisplayName; + private Long clientOfficeId; + + // Loan product attributes + private Long loanProductId; + private String loanProductName; + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class, uses = { LoanStatusEnumData.Mapper.class, CurrencyData.Mapper.class, + LoanPrincipalData.Mapper.class, LoanInterestData.Mapper.class, LoanFeeData.Mapper.class, LoanPenaltyData.Mapper.class, + LoanTotalAmountData.Mapper.class }) + public interface Mapper { + + @Mapping(source = "accountNumber", target = "accountNo") + @Mapping(source = "source", target = "status") + @Mapping(source = "client.id", target = "clientId") + @Mapping(source = "client.accountNumber", target = "clientAccountNo") + @Mapping(source = "client.externalId", target = "clientExternalId") + @Mapping(source = "client.displayName", target = "clientDisplayName") + @Mapping(source = "client.office.id", target = "clientOfficeId") + @Mapping(source = "summary", target = "principal") + @Mapping(source = "summary", target = "interest") + @Mapping(source = "summary", target = "fee") + @Mapping(source = "summary", target = "penalty") + @Mapping(source = "summary", target = "total") + @Mapping(source = "loanProduct.id", target = "loanProductId") + @Mapping(source = "loanProduct.name", target = "loanProductName") + LoanPointInTimeData map(Loan source); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPrincipalData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPrincipalData.java new file mode 100644 index 000000000..fc1c3f6c5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanPrincipalData.java @@ -0,0 +1,46 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import lombok.Data; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.mapstruct.Mapping; + +@Data +public class LoanPrincipalData { + + private final BigDecimal principalDisbursed; + private final BigDecimal principalAdjustments; + private final BigDecimal principalPaid; + private final BigDecimal principalWrittenOff; + private final BigDecimal principalOutstanding; + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class) + public interface Mapper { + + @Mapping(source = "totalPrincipalDisbursed", target = "principalDisbursed") + @Mapping(source = "totalPrincipalAdjustments", target = "principalAdjustments") + @Mapping(source = "totalPrincipalRepaid", target = "principalPaid") + @Mapping(source = "totalPrincipalWrittenOff", target = "principalWrittenOff") + @Mapping(source = "totalPrincipalOutstanding", target = "principalOutstanding") + LoanPrincipalData map(LoanSummary source); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTotalAmountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTotalAmountData.java new file mode 100644 index 000000000..0e2b84388 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTotalAmountData.java @@ -0,0 +1,50 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import lombok.Data; +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.mapstruct.Mapping; + +@Data +public class LoanTotalAmountData { + + private final BigDecimal totalExpectedRepayment; + private final BigDecimal totalRepayment; + private final BigDecimal totalExpectedCostOfLoan; + private final BigDecimal totalCostOfLoan; + private final BigDecimal totalWaived; + private final BigDecimal totalWrittenOff; + private final BigDecimal totalOutstanding; + + @org.mapstruct.Mapper(config = MapstructMapperConfig.class) + public interface Mapper { + + @Mapping(source = "totalExpectedRepayment", target = "totalExpectedRepayment") + @Mapping(source = "totalRepayment", target = "totalRepayment") + @Mapping(source = "totalExpectedCostOfLoan", target = "totalExpectedCostOfLoan") + @Mapping(source = "totalCostOfLoan", target = "totalCostOfLoan") + @Mapping(source = "totalWaived", target = "totalWaived") + @Mapping(source = "totalWrittenOff", target = "totalWrittenOff") + @Mapping(source = "totalOutstanding", target = "totalOutstanding") + LoanTotalAmountData map(LoanSummary source); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeService.java new file mode 100644 index 000000000..acbd4ad9c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeService.java @@ -0,0 +1,30 @@ +/** + * 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.service; + +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.portfolio.loanaccount.data.LoanPointInTimeData; + +public interface LoanPointInTimeService { + + LoanPointInTimeData retrieveAt(Long loanId, LocalDate date); + + List retrieveAt(List loanIds, LocalDate date); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeServiceImpl.java new file mode 100644 index 000000000..7ebf62f4b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanPointInTimeServiceImpl.java @@ -0,0 +1,109 @@ +/** + * 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.service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.loanaccount.data.LoanPointInTimeData; +import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LoanPointInTimeServiceImpl implements LoanPointInTimeService { + + private final LoanUtilService loanUtilService; + private final LoanScheduleService loanScheduleService; + private final LoanAssembler loanAssembler; + private final LoanPointInTimeData.Mapper dataMapper; + + @Override + public LoanPointInTimeData retrieveAt(Long loanId, LocalDate date) { + validateSingularRetrieval(loanId, date); + + // Note: since everything is running in a readOnly transaction + // whatever we modify on the loan is not going to be propagated to the DB + // Note2: Interest is always calculated against the current date of the system so we need to roll time back + HashMap originalBDs = ThreadLocalContextUtil.getBusinessDates(); + try { + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, date))); + + Loan loan = loanAssembler.assembleFrom(loanId); + removeAfterDateTransactions(loan, date); + removeAfterDateCharges(loan, date); + + ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null, null); + loanScheduleService.recalculateSchedule(loan, scheduleGeneratorDTO); + + return dataMapper.map(loan); + } finally { + ThreadLocalContextUtil.setBusinessDates(originalBDs); + } + } + + private void removeAfterDateCharges(Loan loan, LocalDate date) { + loan.removeCharges(c -> DateUtils.isAfter(c.getEffectiveDueDate(), date)); + } + + private void removeAfterDateTransactions(Loan loan, LocalDate date) { + loan.removeLoanTransactions(tx -> DateUtils.isAfter(tx.getTransactionDate(), date)); + } + + private void validateSingularRetrieval(Long loanId, LocalDate date) { + List dataValidationErrors = new ArrayList<>(); + DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan"); + baseDataValidator.reset().parameter("loanId").value(loanId).notNull(); + baseDataValidator.reset().parameter("date").value(date).notNull().notBlank(); + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + @Override + public List retrieveAt(List loanIds, LocalDate date) { + validateBulkRetrieval(loanIds, date); + return loanIds.stream().map(loanId -> retrieveAt(loanId, date)).toList(); + } + + private void validateBulkRetrieval(List loanIds, LocalDate date) { + List dataValidationErrors = new ArrayList<>(); + DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan"); + baseDataValidator.reset().parameter("loanIds").value(loanIds).notNull().listNotEmpty(); + baseDataValidator.reset().parameter("date").value(date).notNull().notBlank(); + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + private void throwExceptionIfValidationWarningsExist(List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } +} 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 e98348858..21abb32fd 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 @@ -141,4 +141,6 @@ public interface LoanReadPlatformService { Long retrieveLoanTransactionIdByExternalId(ExternalId externalId); Long retrieveLoanIdByExternalId(ExternalId externalId); + + List retrieveLoanIdsByExternalIds(List externalIds); } 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 264b4f1e8..cbf86250d 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 @@ -2172,4 +2172,9 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa return loanRepositoryWrapper.findIdByExternalId(externalId); } + @Override + public List retrieveLoanIdsByExternalIds(List externalIds) { + return loanRepositoryWrapper.findIdByExternalIds(externalIds); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 8f7e0d57f..95ae3d15a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -3816,7 +3816,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf && DateUtils.isEqual(loanTermVariations.getTermApplicableFrom(), lastTransactionDate)) || DateUtils.isAfter(loanTermVariations.getTermApplicableFrom(), lastTransactionDate)); reverseExistingTransactionsTillLastDisbursal(loan, lastDisbursalTransaction); - loanScheduleService.recalculateScheduleFromLastTransaction(loan, scheduleGeneratorDTO); + loanScheduleService.recalculateSchedule(loan, scheduleGeneratorDTO); actualChanges.put("undolastdisbursal", "true"); actualChanges.put("disbursedAmount", loan.getDisbursedAmount()); loan.updateLoanSummaryDerivedFields(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java index 2bbeabf45..aeab409e6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java @@ -1088,7 +1088,8 @@ public final class LoanProductDataValidator { && !LoanRescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD.equals(loanRescheduleStrategyMethod)) { baseDataValidator.reset().parameter(LoanProductConstants.rescheduleStrategyMethodParameterName).failWithCode( "reschedule.strategy.method.not.supported.for.loan.schedule.type.progressive", - loanScheduleType.toString() + " reschedule strategy type is not supported for Progressive loan schedule type"); + loanRescheduleStrategyMethod.toString() + + " reschedule strategy type is not supported for Progressive loan schedule type"); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index 4f3b43f0e..2cd667a9a 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -67,6 +67,7 @@ import org.apache.fineract.client.models.GetLoansLoanIdStatus; import org.apache.fineract.client.models.GetLoansLoanIdTransactions; import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse; import org.apache.fineract.client.models.JournalEntryTransactionItem; +import org.apache.fineract.client.models.LoanPointInTimeData; import org.apache.fineract.client.models.PaymentAllocationOrder; import org.apache.fineract.client.models.PostChargesResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; @@ -80,7 +81,9 @@ import org.apache.fineract.client.models.PostLoansRequest; import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; import org.apache.fineract.client.models.PutLoansLoanIdResponse; +import org.apache.fineract.client.models.RetrieveLoansPointInTimeRequest; import org.apache.fineract.client.util.CallFailedRuntimeException; +import org.apache.fineract.client.util.Calls; import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; import org.apache.fineract.integrationtests.client.IntegrationTest; import org.apache.fineract.integrationtests.common.BatchHelper; @@ -706,6 +709,26 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { loanTransactionHelper.undoLastDisbursalLoan(loanId, new PostLoansLoanIdRequest()); } + protected LoanPointInTimeData getPointInTimeData(Long loanId, String date) { + return Calls.ok(fineractClient().loansPointInTimeApi.retrieveLoanPointInTime(loanId, date, DATETIME_PATTERN, "en")); + } + + protected List getPointInTimeData(List loanIds, String date) { + RetrieveLoansPointInTimeRequest request = new RetrieveLoansPointInTimeRequest().loanIds(loanIds).date(date) + .dateFormat(DATETIME_PATTERN).locale("en"); + return Calls.ok(fineractClient().loansPointInTimeApi.retrieveLoansPointInTime(request)); + } + + protected void verifyOutstanding(LoanPointInTimeData loan, OutstandingAmounts outstanding) { + assertThat(BigDecimal.valueOf(outstanding.principalOutstanding)) + .isEqualByComparingTo(loan.getPrincipal().getPrincipalOutstanding()); + assertThat(BigDecimal.valueOf(outstanding.interestOutstanding)).isEqualByComparingTo(loan.getInterest().getInterestOutstanding()); + assertThat(BigDecimal.valueOf(outstanding.feeOutstanding)).isEqualByComparingTo(loan.getFee().getFeeChargesOutstanding()); + assertThat(BigDecimal.valueOf(outstanding.penaltyOutstanding)) + .isEqualByComparingTo(loan.getPenalty().getPenaltyChargesOutstanding()); + assertThat(BigDecimal.valueOf(outstanding.totalOutstanding)).isEqualByComparingTo(loan.getTotal().getTotalOutstanding()); + } + // Note: this is buggy because if multiple journal entries are for the same account, amount and type, the // verification will pass // not all journal entries have been validated - since there might be duplicates @@ -1158,8 +1181,8 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { loanBalance); } - protected OutstandingAmounts outstanding(double principal, double fee, double penalty, double total) { - return new OutstandingAmounts(principal, fee, penalty, total); + protected OutstandingAmounts outstanding(double principal, double interestOutstanding, double fee, double penalty, double total) { + return new OutstandingAmounts(principal, interestOutstanding, fee, penalty, total); } protected BatchRequestBuilder batchRequest() { @@ -1321,6 +1344,7 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { public static class OutstandingAmounts { Double principalOutstanding; + Double interestOutstanding; Double feeOutstanding; Double penaltyOutstanding; Double totalOutstanding; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java index 3d86f085e..dfd373df3 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java @@ -597,7 +597,7 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa installment(312.0, 0, 0, 0, 0, true, "01 March 2023"), // installment(312.0, 0, 0, 0, 0, true, "01 April 2023"), // installment(314.0, 0, 0, 0, 0, true, "01 May 2023"), // - installment(100.0, 0, 0, 0, outstanding(50.0, 0d, 0d, 50.0), false, "02 May 2023") // + installment(100.0, 0, 0, 0, outstanding(50.0, 0.0, 0d, 0d, 50.0), false, "02 May 2023") // ); }); } @@ -659,7 +659,7 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa // DEFAULT payment allocation is ..., DUE_PENALTY, DUE_FEE, DUE_PRINCIPAL, ... verifyRepaymentSchedule(loanId, // installment(1250.0, null, "01 January 2023"), // - installment(342.0, 0, 100, 40, outstanding(30.0, 20.0, 0.0, 50.0), false, "01 February 2023"), // + installment(342.0, 0, 100, 40, outstanding(30.0, 0.0, 20.0, 0.0, 50.0), false, "01 February 2023"), // installment(312.0, 0, 0, 0, 0.0, true, "01 March 2023"), // installment(312.0, 0, 0, 0, 0.0, true, "01 April 2023"), // installment(314.0, 0, 0, 0, 0.0, true, "01 May 2023") // @@ -726,7 +726,7 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa // DEFAULT payment allocation is ..., DUE_PRINCIPAL, DUE_FEE, DUE_PENALTY ... verifyRepaymentSchedule(loanId, // installment(1250.0, null, "01 January 2023"), // - installment(342.0, 0, 100, 40, outstanding(0.0, 30.0, 20.0, 50.0), false, "01 February 2023"), // + installment(342.0, 0, 100, 40, outstanding(0.0, 0.0, 30.0, 20.0, 50.0), false, "01 February 2023"), // installment(312.0, 0, 0, 0, 0.0, true, "01 March 2023"), // installment(312.0, 0, 0, 0, 0.0, true, "01 April 2023"), // installment(314.0, 0, 0, 0, 0.0, true, "01 May 2023") // diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/pointintime/LoanPointInTimeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/pointintime/LoanPointInTimeTest.java new file mode 100644 index 000000000..16eb4405e --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/pointintime/LoanPointInTimeTest.java @@ -0,0 +1,659 @@ +/** + * 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.loan.pointintime; + +import static org.apache.fineract.integrationtests.BaseLoanIntegrationTest.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.fineract.client.models.ChargeData; +import org.apache.fineract.client.models.LoanPointInTimeData; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PostLoansRequestChargeData; +import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.integrationtests.BaseLoanIntegrationTest; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.junit.jupiter.api.Test; + +public class LoanPointInTimeTest extends BaseLoanIntegrationTest { + + @Test + public void test_LoanPointInTimeDataWorks_ForPrincipalOutstandingCalculation() { + AtomicReference aLoanId = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 1; + + // Create charges + double charge1Amount = 1.0; + double charge2Amount = 1.5; + Long charge1Id = createDisbursementPercentageCharge(charge1Amount); + Long charge2Id = createDisbursementPercentageCharge(charge2Amount); + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null)// + .charges(List.of(new ChargeData().id(charge1Id), new ChargeData().id(charge2Id)));// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .charges(List.of(// + new PostLoansRequestChargeData().chargeId(charge1Id).amount(BigDecimal.valueOf(charge1Amount)), // + new PostLoansRequestChargeData().chargeId(charge2Id).amount(BigDecimal.valueOf(charge2Amount))// + ));// + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + Long loanId = aLoanId.get(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023") // + ); + }); + + runAt("01 February 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "01 February 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 February 2023") // + ); + }); + + runAt("09 February 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "09 February 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + + pointInTimeData = getPointInTimeData(loanId, "07 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + + pointInTimeData = getPointInTimeData(loanId, "09 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4000.0, 0.0, 0.0, 0.0, 4000.0)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023") // + ); + }); + + runAt("01 March 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "01 March 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + + pointInTimeData = getPointInTimeData(loanId, "10 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4000.0, 0.0, 0.0, 0.0, 4000.0)); + + pointInTimeData = getPointInTimeData(loanId, "01 March 2023"); + verifyOutstanding(pointInTimeData, outstanding(3500.0, 0.0, 0.0, 0.0, 3500.0)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023"), // + transaction(500.0, "Repayment", "01 March 2023") // + ); + }); + } + + @Test + public void test_LoanPointInTimeDataWorks_ForAllOutstandingCalculation_WhenLoanIsCumulative_AndInterestIsEnabled() { + AtomicReference aLoanId = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 1; + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestRatePerPeriod(10.0)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null);// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .interestRatePerPeriod(BigDecimal.valueOf(10.0))// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY);// + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + Long loanId = aLoanId.get(); + + Long chargeId = createCharge(100.0).getResourceId(); + addLoanCharge(loanId, chargeId, "02 February 2023", 100.0); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023") // + ); + }); + + runAt("01 February 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 250.0, "01 February 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(5000.0, 767.70, 0.0, 0.0, 5767.70)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023") // + ); + }); + + runAt("09 February 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "09 February 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(5000.0, 767.70, 0.0, 0.0, 5767.70)); + + pointInTimeData = getPointInTimeData(loanId, "07 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(5000.0, 800.22, 100.0, 0.0, 5900.22)); + + pointInTimeData = getPointInTimeData(loanId, "09 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4759.59, 551.47, 100.0, 0.0, 5411.06)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023") // + ); + }); + + runAt("01 March 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "01 March 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(5000.0, 767.70, 0.0, 0.0, 5767.70)); + + pointInTimeData = getPointInTimeData(loanId, "10 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4759.59, 556.02, 100.0, 0.0, 5415.61)); + + pointInTimeData = getPointInTimeData(loanId, "01 March 2023"); + verifyOutstanding(pointInTimeData, outstanding(4259.59, 642.46, 100.0, 0.0, 5002.05)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023"), // + transaction(500.0, "Repayment", "01 March 2023") // + ); + }); + + runAt("05 March 2023", () -> { + Long loanId = aLoanId.get(); + + // repay full loan + addRepaymentForLoan(loanId, 5032.52, "05 March 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(5000.0, 767.70, 0.0, 0.0, 5767.70)); + + pointInTimeData = getPointInTimeData(loanId, "10 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4759.59, 556.02, 100.0, 0.0, 5415.61)); + + pointInTimeData = getPointInTimeData(loanId, "01 March 2023"); + verifyOutstanding(pointInTimeData, outstanding(4259.59, 642.46, 100.0, 0.0, 5002.05)); + + pointInTimeData = getPointInTimeData(loanId, "05 March 2023"); + verifyOutstanding(pointInTimeData, outstanding(0.0, 0.0, 0.0, 0.0, 0.0)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023"), // + transaction(500.0, "Repayment", "01 March 2023"), // + transaction(5032.52, "Repayment", "05 March 2023"), // + transaction(1282.52, "Accrual", "05 March 2023") // + ); + }); + } + + @Test + public void test_LoanPointInTimeDataWorks_ForAllOutstandingCalculation_WhenLoanIsProgressive_AndInterestIsEnabled() { + AtomicReference aLoanId = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 1; + + // Create Loan Product + PostLoanProductsRequest product = create4IProgressive() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestRatePerPeriod(10.0)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .currencyCode("USD").multiDisburseLoan(null);// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY).interestRatePerPeriod(BigDecimal.valueOf(10.0))// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY);// + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + Long loanId = aLoanId.get(); + + Long chargeId = createCharge(100.0).getResourceId(); + addLoanCharge(loanId, chargeId, "02 February 2023", 100.0); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023") // + ); + }); + + runAt("01 February 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 250.0, "01 February 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4750.0, 83.56, 0.0, 0.0, 4833.56)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023") // + ); + }); + + runAt("09 February 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "09 February 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4750.0, 83.56, 0.0, 0.0, 4833.56)); + + pointInTimeData = getPointInTimeData(loanId, "07 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4750.0, 86.10, 100.0, 0.0, 4936.10)); + + pointInTimeData = getPointInTimeData(loanId, "09 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4250.0, 86.93, 100.0, 0.0, 4436.93)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023") // + ); + }); + + runAt("01 March 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "01 March 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4750.0, 83.56, 0.0, 0.0, 4833.56)); + + pointInTimeData = getPointInTimeData(loanId, "10 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4250.0, 87.20, 100.0, 0.0, 4437.20)); + + pointInTimeData = getPointInTimeData(loanId, "01 March 2023"); + verifyOutstanding(pointInTimeData, outstanding(3750.0, 92.36, 100.0, 0.0, 3942.36)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023"), // + transaction(500.0, "Repayment", "01 March 2023") // + ); + }); + + runAt("05 March 2023", () -> { + Long loanId = aLoanId.get(); + + // repay full loan + addRepaymentForLoan(loanId, 3942.36, "05 March 2023"); + + // check point in time data + LoanPointInTimeData pointInTimeData = getPointInTimeData(loanId, "01 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4750.0, 83.56, 0.0, 0.0, 4833.56)); + + pointInTimeData = getPointInTimeData(loanId, "10 February 2023"); + verifyOutstanding(pointInTimeData, outstanding(4250.0, 87.20, 100.0, 0.0, 4437.20)); + + pointInTimeData = getPointInTimeData(loanId, "01 March 2023"); + verifyOutstanding(pointInTimeData, outstanding(3750.0, 92.36, 100.0, 0.0, 3942.36)); + + pointInTimeData = getPointInTimeData(loanId, "05 March 2023"); + verifyOutstanding(pointInTimeData, outstanding(0.0, 0.0, 0.0, 0.0, 0.0)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(250.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "09 February 2023"), // + transaction(500.0, "Repayment", "01 March 2023"), // + transaction(3942.36, "Repayment", "05 March 2023"), // + transaction(182.31, "Accrual", "05 March 2023") // + ); + }); + } + + @Test + public void test_LoansPointInTimeDataWorks_ForPrincipalOutstandingCalculation() { + AtomicReference aLoanId = new AtomicReference<>(); + AtomicReference aLoanId2 = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 1; + + // Create charges + double charge1Amount = 1.0; + double charge2Amount = 1.5; + Long charge1Id = createDisbursementPercentageCharge(charge1Amount); + Long charge2Id = createDisbursementPercentageCharge(charge2Amount); + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null)// + .charges(List.of(new ChargeData().id(charge1Id), new ChargeData().id(charge2Id)));// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .charges(List.of(// + new PostLoansRequestChargeData().chargeId(charge1Id).amount(BigDecimal.valueOf(charge1Amount)), // + new PostLoansRequestChargeData().chargeId(charge2Id).amount(BigDecimal.valueOf(charge2Amount))// + ));// + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + PostLoansResponse postLoansResponse2 = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + PostLoansLoanIdResponse approvedLoanResult2 = loanTransactionHelper.approveLoan(postLoansResponse2.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + aLoanId2.getAndSet(approvedLoanResult2.getLoanId()); + Long loanId = aLoanId.get(); + Long loanId2 = aLoanId2.get(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + disburseLoan(loanId2, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023") // + ); + verifyTransactions(loanId2, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023") // + ); + }); + + runAt("01 February 2023", () -> { + Long loanId = aLoanId.get(); + Long loanId2 = aLoanId2.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "01 February 2023"); + addRepaymentForLoan(loanId2, 500.0, "01 February 2023"); + + // check point in time data + List pointInTimeData = getPointInTimeData(List.of(loanId, loanId2), "01 February 2023"); + verifyOutstanding(pointInTimeData.get(0), outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + verifyOutstanding(pointInTimeData.get(1), outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 February 2023") // + ); + verifyTransactions(loanId2, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 February 2023") // + ); + }); + + runAt("01 March 2023", () -> { + Long loanId = aLoanId.get(); + Long loanId2 = aLoanId2.get(); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "01 March 2023"); + addRepaymentForLoan(loanId2, 500.0, "01 March 2023"); + + // check point in time data + List pointInTimeData = getPointInTimeData(List.of(loanId, loanId2), "01 February 2023"); + verifyOutstanding(pointInTimeData.get(0), outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + verifyOutstanding(pointInTimeData.get(1), outstanding(4500.0, 0.0, 0.0, 0.0, 4500.0)); + + pointInTimeData = getPointInTimeData(List.of(loanId, loanId2), "01 March 2023"); + verifyOutstanding(pointInTimeData.get(0), outstanding(4000.0, 0.0, 0.0, 0.0, 4000.0)); + verifyOutstanding(pointInTimeData.get(1), outstanding(4000.0, 0.0, 0.0, 0.0, 4000.0)); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "01 March 2023") // + ); + verifyTransactions(loanId2, // + transaction(5000.0, "Disbursement", "01 January 2023"), // + transaction(125.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 February 2023"), // + transaction(500.0, "Repayment", "01 March 2023") // + ); + }); + } +}