FINERACT-2178: Ability to retrieve a loan's balance at a certain point in time in the past

This commit is contained in:
Arnold Galovics
2025-02-17 21:48:05 +01:00
committed by Arnold Gálovics
parent 986dceea1c
commit 0f1be3c913
29 changed files with 1523 additions and 8 deletions
@@ -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);
@@ -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<Object> list = (List<Object>) 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;
@@ -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<ExternalId> produce(List<String> 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) {
@@ -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();
}
}
}
@@ -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());
}
}
}
@@ -2693,11 +2693,15 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return changedTransactionDetail;
}
/*
* Probably this is buggy when a chargeback transaction happens
*/
public void processPostDisbursementTransactions() {
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = getTransactionProcessor();
final List<LoanTransaction> allNonContraTransactionsPostDisbursement = retrieveListOfTransactionsForReprocessing();
final List<LoanTransaction> 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<Long> {
return Optional.ofNullable(this.charges).orElse(new HashSet<>());
}
public void removeCharges(Predicate<LoanCharge> predicate) {
charges.removeIf(predicate);
}
public boolean hasDelinquencyBucket() {
return (getLoanProduct().getDelinquencyBucket() != null);
}
@@ -3537,6 +3545,10 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return getLoanTransactions().stream().filter(predicate).findFirst().orElse(null);
}
public void removeLoanTransactions(Predicate<LoanTransaction> predicate) {
loanTransactions.removeIf(predicate);
}
public LoanTransaction findChargedOffTransaction() {
return getLoanTransaction(e -> e.isNotReversed() && e.isChargeOff());
}
@@ -85,6 +85,8 @@ public interface LoanRepository extends JpaRepository<Loan, Long>, 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<Loan, Long>, JpaSpecificat
@Query(FIND_ID_BY_EXTERNAL_ID)
Long findIdByExternalId(@Param("externalId") ExternalId externalId);
@Query(FIND_IDS_BY_EXTERNAL_IDS)
List<Long> findIdsByExternalIds(@Param("externalIds") List<ExternalId> externalIds);
@Query(FIND_ALL_NON_CLOSED_LOANS_BEHIND_BY_LOAN_IDS)
List<LoanIdAndLastClosedBusinessDate> findAllNonClosedLoansBehindByLoanIds(@Param("cobBusinessDate") LocalDate cobBusinessDate,
@Param("loanIds") List<Long> loanIds);
@@ -285,4 +285,7 @@ public class LoanRepositoryWrapper {
return repository.findLoansForAddAccrual(accountingType, tillDate, futureCharges);
}
public List<Long> findIdByExternalIds(List<ExternalId> externalIds) {
return repository.findIdsByExternalIds(externalIds);
}
}
@@ -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 {
@@ -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<LoanPointInTimeData> retrieveLoansPointInTime(RetrieveLoansPointInTimeRequest request);
List<LoanPointInTimeData> retrieveLoansPointInTimeByExternalIds(RetrieveLoansPointInTimeExternalIdsRequest request);
}
@@ -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<LoanPointInTimeData> retrieveLoansPointInTime(RetrieveLoansPointInTimeRequest request) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
List<Long> loanIds = request.getLoanIds();
DateParam dateParam = request.getDate();
String dateFormat = request.getDateFormat();
String locale = request.getLocale();
return getLoansPointInTime(loanIds, dateParam, dateFormat, locale);
}
@Override
public List<LoanPointInTimeData> retrieveLoansPointInTimeByExternalIds(RetrieveLoansPointInTimeExternalIdsRequest request) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
List<String> loanExternalIds = request.getExternalIds();
DateParam dateParam = request.getDate();
String dateFormat = request.getDateFormat();
String locale = request.getLocale();
List<ExternalId> externalIds = ExternalIdFactory.produce(loanExternalIds);
List<Long> loanIds = resolveExternalIds(externalIds);
return getLoansPointInTime(loanIds, dateParam, dateFormat, locale);
}
private List<LoanPointInTimeData> getLoansPointInTime(List<Long> 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<Long> resolveExternalIds(List<ExternalId> 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;
}
}
@@ -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<LoanPointInTimeData> retrieveLoansPointInTime(@Parameter RetrieveLoansPointInTimeRequest request) {
return delegate.retrieveLoansPointInTime(request);
}
@POST
@Path("search/external-id")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Override
public List<LoanPointInTimeData> retrieveLoansPointInTimeByExternalIds(@Parameter RetrieveLoansPointInTimeExternalIdsRequest request) {
return delegate.retrieveLoansPointInTimeByExternalIds(request);
}
}
@@ -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<String> externalIds;
private DateParam date;
private String dateFormat;
private String locale;
}
@@ -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<Long> loanIds;
private DateParam date;
private String dateFormat;
private String locale;
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<LoanPointInTimeData> retrieveAt(List<Long> loanIds, LocalDate date);
}
@@ -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<BusinessDateType, LocalDate> 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<ApiParameterError> 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<LoanPointInTimeData> retrieveAt(List<Long> loanIds, LocalDate date) {
validateBulkRetrieval(loanIds, date);
return loanIds.stream().map(loanId -> retrieveAt(loanId, date)).toList();
}
private void validateBulkRetrieval(List<Long> loanIds, LocalDate date) {
List<ApiParameterError> 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<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -141,4 +141,6 @@ public interface LoanReadPlatformService {
Long retrieveLoanTransactionIdByExternalId(ExternalId externalId);
Long retrieveLoanIdByExternalId(ExternalId externalId);
List<Long> retrieveLoanIdsByExternalIds(List<ExternalId> externalIds);
}
@@ -2172,4 +2172,9 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
return loanRepositoryWrapper.findIdByExternalId(externalId);
}
@Override
public List<Long> retrieveLoanIdsByExternalIds(List<ExternalId> externalIds) {
return loanRepositoryWrapper.findIdByExternalIds(externalIds);
}
}
@@ -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();
@@ -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");
}
}
@@ -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<LoanPointInTimeData> getPointInTimeData(List<Long> 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;
@@ -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") //
@@ -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<Long> 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<Long> 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<Long> 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<Long> aLoanId = new AtomicReference<>();
AtomicReference<Long> 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<LoanPointInTimeData> 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<LoanPointInTimeData> 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") //
);
});
}
}