FINERACT-2220: Make ProgressiveLoanInterestScheduleModel serializable and deserializable

This commit is contained in:
Soma Sörös
2025-04-09 10:12:51 +02:00
committed by Adam Saghy
parent 8f6b8cbc47
commit 7f9a812048
24 changed files with 1120 additions and 1 deletions
@@ -107,6 +107,7 @@ import org.apache.fineract.client.services.PaymentTypeApi;
import org.apache.fineract.client.services.PeriodicAccrualAccountingApi; import org.apache.fineract.client.services.PeriodicAccrualAccountingApi;
import org.apache.fineract.client.services.PermissionsApi; import org.apache.fineract.client.services.PermissionsApi;
import org.apache.fineract.client.services.PocketApi; import org.apache.fineract.client.services.PocketApi;
import org.apache.fineract.client.services.ProgressiveLoanApi;
import org.apache.fineract.client.services.ProvisioningCategoryApi; import org.apache.fineract.client.services.ProvisioningCategoryApi;
import org.apache.fineract.client.services.ProvisioningCriteriaApi; import org.apache.fineract.client.services.ProvisioningCriteriaApi;
import org.apache.fineract.client.services.ProvisioningEntriesApi; import org.apache.fineract.client.services.ProvisioningEntriesApi;
@@ -294,6 +295,7 @@ public final class FineractClient {
public final UsersApi users; public final UsersApi users;
public final WorkingDaysApi workingDays; public final WorkingDaysApi workingDays;
public final LoanInterestPauseApi loanInterestPauseApi; public final LoanInterestPauseApi loanInterestPauseApi;
public final ProgressiveLoanApi progressiveLoanApi;
public final ExternalAssetOwnersApi externalAssetOwners; public final ExternalAssetOwnersApi externalAssetOwners;
public final ExternalAssetOwnerLoanProductAttributesApi externalAssetOwnerLoanProductAttributes; public final ExternalAssetOwnerLoanProductAttributesApi externalAssetOwnerLoanProductAttributes;
@@ -421,6 +423,7 @@ public final class FineractClient {
users = retrofit.create(UsersApi.class); users = retrofit.create(UsersApi.class);
workingDays = retrofit.create(WorkingDaysApi.class); workingDays = retrofit.create(WorkingDaysApi.class);
loanInterestPauseApi = retrofit.create(LoanInterestPauseApi.class); loanInterestPauseApi = retrofit.create(LoanInterestPauseApi.class);
progressiveLoanApi = retrofit.create(ProgressiveLoanApi.class);
} }
public static Builder builder() { public static Builder builder() {
@@ -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.infrastructure.core.serialization.gson;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Exclude field from json serialization when using Gson with custom
* {@link JsonExcludeAnnotationBasedExclusionStrategy}.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD })
public @interface JsonExclude {}
@@ -0,0 +1,40 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.serialization.gson;
import com.google.gson.ExclusionStrategy;
import com.google.gson.FieldAttributes;
/**
* Exclusion strategy to handle {@link JsonExclude} annotations to exclude fields from serialization and
* deserialization.
*/
public class JsonExcludeAnnotationBasedExclusionStrategy implements ExclusionStrategy {
@Override
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
JsonExclude annotation = fieldAttributes.getAnnotation(JsonExclude.class);
return annotation != null;
}
@Override
public boolean shouldSkipClass(Class<?> aClass) {
return false;
}
}
@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.serialization.gson;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class LocalDateAdapter extends TypeAdapter<LocalDate> {
@Override
public void write(final JsonWriter jsonWriter, final LocalDate localDate) throws IOException {
jsonWriter.value(localDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
}
@Override
public LocalDate read(final JsonReader jsonReader) throws IOException {
return LocalDate.parse(jsonReader.nextString(), DateTimeFormatter.ISO_LOCAL_DATE);
}
}
@@ -0,0 +1,43 @@
/**
* 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.organisation.monetary.serialization.gson;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import java.lang.reflect.Type;
import java.math.MathContext;
import lombok.AllArgsConstructor;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@AllArgsConstructor
public class MoneyDeserializer implements JsonDeserializer<Money> {
private final MathContext mc;
private final MonetaryCurrency currency;
@Override
public Money deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext)
throws JsonParseException {
return Money.of(currency, jsonElement.getAsBigDecimal(), mc);
}
}
@@ -0,0 +1,35 @@
/**
* 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.organisation.monetary.serialization.gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import java.lang.reflect.Type;
import org.apache.fineract.organisation.monetary.domain.Money;
public class MoneySerializer implements JsonSerializer<Money> {
@Override
public JsonElement serialize(Money money, Type type, JsonSerializationContext jsonSerializationContext) {
return new JsonPrimitive(money.getAmount());
}
}
@@ -0,0 +1,55 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_loan_progressive_model")
@Getter
@Setter
public class ProgressiveLoanModel extends AbstractPersistableCustom<Long> {
@Version
int version;
@OneToOne
@JoinColumn(name = "loan_id", nullable = false)
private Loan loan;
@Column(name = "json_model", columnDefinition = "text", nullable = false)
private String jsonModel;
@Column(name = "business_date", nullable = false)
private LocalDate businessDate;
@Column(name = "last_modified_on_utc", nullable = false)
private OffsetDateTime lastModifiedDate;
}
@@ -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.repository;
import java.util.Optional;
import org.apache.fineract.portfolio.loanaccount.domain.ProgressiveLoanModel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface ProgressiveLoanModelRepository
extends JpaSpecificationExecutor<ProgressiveLoanModel>, JpaRepository<ProgressiveLoanModel, Long> {
Optional<ProgressiveLoanModel> findOneByLoanId(Long loanId);
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.service;
import java.util.Optional;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
public interface InterestScheduleModelRepositoryWrapper {
String writeInterestScheduleModel(Loan loan, ProgressiveLoanInterestScheduleModel model);
Optional<ProgressiveLoanInterestScheduleModel> readProgressiveLoanInterestScheduleModel(Long loanId,
LoanProductMinimumRepaymentScheduleRelatedDetail detail, Integer installmentAmountInMultipliesOf);
}
@@ -0,0 +1,68 @@
/**
* 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 jakarta.transaction.Transactional;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.ProgressiveLoanModel;
import org.apache.fineract.portfolio.loanaccount.repository.ProgressiveLoanModelRepository;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class InterestScheduleModelRepositoryWrapperImpl implements InterestScheduleModelRepositoryWrapper {
private final ProgressiveLoanModelRepository loanModelRepository;
private final ProgressiveLoanInterestScheduleModelParserService progressiveLoanInterestScheduleModelParserService;
@Transactional
@Override
public String writeInterestScheduleModel(Loan loan, ProgressiveLoanInterestScheduleModel model) {
if (model == null) {
return null;
}
String jsonModel = progressiveLoanInterestScheduleModelParserService.toJson(model);
ProgressiveLoanModel progressiveLoanModel = loanModelRepository.findOneByLoanId(loan.getId()).orElseGet(() -> {
ProgressiveLoanModel plm = new ProgressiveLoanModel();
plm.setLoan(loan);
return plm;
});
progressiveLoanModel.setBusinessDate(ThreadLocalContextUtil.getBusinessDate());
progressiveLoanModel.setLastModifiedDate(DateUtils.getAuditOffsetDateTime());
progressiveLoanModel.setJsonModel(jsonModel);
loanModelRepository.save(progressiveLoanModel);
return jsonModel;
}
@Override
public Optional<ProgressiveLoanInterestScheduleModel> readProgressiveLoanInterestScheduleModel(final Long loanId,
final LoanProductMinimumRepaymentScheduleRelatedDetail detail, final Integer installmentAmountInMultipliesOf) {
return loanModelRepository.findOneByLoanId(loanId) //
.map(ProgressiveLoanModel::getJsonModel) //
.map(jsonModel -> progressiveLoanInterestScheduleModelParserService.fromJson(jsonModel, detail,
MoneyHelper.getMathContext(), installmentAmountInMultipliesOf)); //
}
}
@@ -0,0 +1,66 @@
/**
* 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.lang.reflect.Type;
import java.math.MathContext;
import java.util.ArrayList;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.portfolio.loanproduct.calc.data.InterestPeriod;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.calc.data.RepaymentPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
@Data
@RequiredArgsConstructor
public class InterestScheduleModelServiceGsonContext {
private final MonetaryCurrency currency;
private final MathContext mc;
private final LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail;
private RepaymentPeriod prev = null;
private final Integer installmentAmountInMultipliesOf;
public RepaymentPeriod createRepaymentPeriodInstance(Type type) {
if (type == RepaymentPeriod.class) {
setPrev(RepaymentPeriod.empty(getPrev(), getMc()));
return getPrev();
}
throw new IllegalArgumentException("Unsupported RepaymentPeriod type: " + type);
}
public InterestPeriod createInterestPeriodInstance(Type type) {
if (type == InterestPeriod.class) {
return InterestPeriod.empty(getPrev(), getMc());
}
throw new IllegalArgumentException("Unsupported InterestPeriod type: " + type);
}
public ProgressiveLoanInterestScheduleModel createProgressiveLoanInterestScheduleModelInstance(Type type) {
if (type == ProgressiveLoanInterestScheduleModel.class) {
setPrev(null);
return new ProgressiveLoanInterestScheduleModel(new ArrayList<>(), getLoanProductRelatedDetail(), new ArrayList<>(),
installmentAmountInMultipliesOf, getMc());
}
throw new IllegalArgumentException("Unsupported ProgressiveLoanInterestScheduleModel type: " + type);
}
}
@@ -0,0 +1,129 @@
/**
* 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 edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
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.core.MediaType;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.boot.FineractProfiles;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Profile(FineractProfiles.TEST)
@Component
@Path("v1/internal/loan/progressive")
@Tag(name = "Progressive Loan", description = "internal loan testing API. This API should be disabled in production!!!")
public class InternalProgressiveLoanApiResource implements InitializingBean {
private final LoanRepositoryWrapper loanRepository;
private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor;
private final ProgressiveLoanInterestScheduleModelParserService progressiveLoanInterestScheduleModelParserService;
private final InterestScheduleModelRepositoryWrapper writePlatformService;
@Override
@SuppressFBWarnings("SLF4J_SIGN_ONLY_FORMAT")
public void afterPropertiesSet() throws Exception {
log.warn("------------------------------------------------------------");
log.warn(" ");
log.warn("DO NOT USE THIS IN PRODUCTION!");
log.warn("Internal client services mode is enabled");
log.warn("DO NOT USE THIS IN PRODUCTION!");
log.warn(" ");
log.warn("------------------------------------------------------------");
}
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Path("{loanId}/model")
@Operation(summary = "Fetch ProgressiveLoanInterestScheduleModel", description = "DO NOT USE THIS IN PRODUCTION!")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = InternalProgressiveLoanApiResourceSwagger.ProgressiveLoanInterestScheduleModel.class))) })
public String fetchModel(@PathParam("loanId") @Parameter(description = "loanId") long loanId) {
Loan loan = loanRepository.findOneWithNotFoundDetection(loanId);
if (!loan.isProgressiveSchedule()) {
throw new IllegalArgumentException("The loan is not progressive.");
}
return writePlatformService
.readProgressiveLoanInterestScheduleModel(loanId, loan.getLoanRepaymentScheduleDetail(),
loan.getLoanProduct().getInstallmentAmountInMultiplesOf())
.map(progressiveLoanInterestScheduleModelParserService::toJson).orElse(null);
}
private ProgressiveLoanInterestScheduleModel reprocessTransactionsAndGetModel(Loan loan) {
List<LoanTransaction> transactionsToReprocess = loan.retrieveListOfTransactionsForReprocessing();
LocalDate businessDate = ThreadLocalContextUtil.getBusinessDate();
Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> changedTransactionDetailProgressiveLoanInterestScheduleModelPair = advancedPaymentScheduleTransactionProcessor
.reprocessProgressiveLoanTransactions(loan.getDisbursementDate(), businessDate, transactionsToReprocess, loan.getCurrency(),
loan.getRepaymentScheduleInstallments(), loan.getActiveCharges());
ProgressiveLoanInterestScheduleModel model = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getRight();
final List<Long> replayedTransactions = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft()
.getTransactionChanges().stream().filter(change -> change.getOldTransaction() != null)
.map(change -> change.getNewTransaction().getId()).filter(Objects::nonNull).toList();
if (!replayedTransactions.isEmpty()) {
log.warn("Reprocessed transactions show differences: There are unsaved changes of the following transactions: {}",
replayedTransactions);
}
return model;
}
@POST
@Path("{loanId}/model")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update and Save ProgressiveLoanInterestScheduleModel", description = "DO NOT USE THIS IN PRODUCTION!")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = InternalProgressiveLoanApiResourceSwagger.ProgressiveLoanInterestScheduleModel.class))) })
public String updateModel(@PathParam("loanId") @Parameter(description = "loanId") long loanId) {
Loan loan = loanRepository.findOneWithNotFoundDetection(loanId);
if (!loan.isProgressiveSchedule()) {
throw new IllegalArgumentException("The loan is not progressive.");
}
ProgressiveLoanInterestScheduleModel model = reprocessTransactionsAndGetModel(loan);
return writePlatformService.writeInterestScheduleModel(loan, model);
}
}
@@ -0,0 +1,126 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
final class InternalProgressiveLoanApiResourceSwagger {
@Schema(description = "ProgressiveLoanInterestScheduleModel")
static final class ProgressiveLoanInterestScheduleModel {
private ProgressiveLoanInterestScheduleModel() {}
@Schema(example = "[]")
public List<RepaymentPeriod> repaymentPeriods;
@Schema(example = "[]")
public TreeSet<InterestRate> interestRates;
@Schema(example = "{}")
public Map<Integer, List<LoanTermVariationsData>> loanTermVariations;
@Schema(example = "1")
public Integer installmentAmountInMultiplesOf;
@Schema(example = "{}")
public Map<Integer, Boolean> modifiers;
}
@Schema(description = "Interest Period")
static final class InterestPeriod {
private InterestPeriod() {}
@Schema(example = "01/01/2024")
public String fromDate;
@Schema(example = "01/09/2024")
public String dueDate;
@Schema(example = "0.9636548454")
public BigDecimal rateFactor;
@Schema(example = "0.9456878987")
public BigDecimal rateFactorTillPeriodDueDate;
@Schema(example = "0.0")
public BigDecimal chargebackPrincipal;
@Schema(example = "0.0")
public BigDecimal chargebackInterest;
@Schema(example = "1000.0")
public BigDecimal disbursementAmount;
@Schema(example = "3.38")
public BigDecimal balanceCorrectionAmount;
@Schema(example = "865.71")
public BigDecimal outstandingLoanBalance;
@Schema(example = "false")
public boolean isPaused;
}
@Schema(description = "Repayment Period")
static final class RepaymentPeriod {
private RepaymentPeriod() {}
@Schema(example = "01/01/2024")
public String fromDate;
@Schema(example = "01/02/2024")
public String dueDate;
@Schema(example = "[]")
public List<InterestPeriod> interestPeriods;
@Schema(example = "127.04")
public BigDecimal emi;
@Schema(example = "127.04")
public BigDecimal originalEmi;
@Schema(example = "104.04")
public BigDecimal paidPrincipal;
@Schema(example = "23.00")
public BigDecimal paidInterest;
}
@Schema(description = "Interest Rate")
static final class InterestRate {
private InterestRate() {}
@Schema(example = "21/12/2024")
public String effectiveFrom;
@Schema(example = "7.963")
public BigDecimal interestRate;
}
@Schema(description = "Loan Term Variations Data")
static final class LoanTermVariationsData {
private LoanTermVariationsData() {}
@Schema(example = "12345")
public Long id;
@Schema(example = "null")
public EnumOptionData termType;
@Schema(example = "21/12/2024")
public String termVariationApplicableFrom;
@Schema(example = "1.20")
public BigDecimal decimalValue;
@Schema(example = "21/12/2024")
public String dateValue;
@Schema(example = "true")
public boolean isSpecificToInstallment;
@Schema(example = "true")
public Boolean isProcessed;
}
}
@@ -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.service;
import org.apache.fineract.portfolio.loanaccount.starter.AdvancedPaymentScheduleTransactionProcessorCondition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
@Configuration
@Conditional(AdvancedPaymentScheduleTransactionProcessorCondition.class)
public class ProgressiveLoanConfiguration {
@Bean
@ConditionalOnMissingBean
public ProgressiveLoanInterestScheduleModelParserService interestScheduleModelService() {
return new ProgressiveLoanInterestScheduleModelParserServiceGsonImpl();
}
}
@@ -0,0 +1,37 @@
/**
* 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.math.MathContext;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
public interface ProgressiveLoanInterestScheduleModelParserService {
/**
* Json stringify a ProgressiveLoanInterestScheduleModel model.
*/
String toJson(ProgressiveLoanInterestScheduleModel model);
/**
* Restore a ProgressiveLoanInterestScheduleModel from a JSON string.
*/
ProgressiveLoanInterestScheduleModel fromJson(String s, LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail,
MathContext mc, Integer installmentAmountInMultipliesOf);
}
@@ -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.service;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.InstanceCreator;
import com.google.gson.ToNumberPolicy;
import jakarta.validation.constraints.NotNull;
import java.math.MathContext;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.serialization.gson.JsonExcludeAnnotationBasedExclusionStrategy;
import org.apache.fineract.infrastructure.core.serialization.gson.LocalDateAdapter;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.serialization.gson.MoneyDeserializer;
import org.apache.fineract.organisation.monetary.serialization.gson.MoneySerializer;
import org.apache.fineract.portfolio.loanproduct.calc.data.InterestPeriod;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanproduct.calc.data.RepaymentPeriod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
@Slf4j
@RequiredArgsConstructor
public class ProgressiveLoanInterestScheduleModelParserServiceGsonImpl implements ProgressiveLoanInterestScheduleModelParserService {
private final Gson gsonSerializer = createSerializer();
private Gson createSerializer() {
return new GsonBuilder().registerTypeAdapter(LocalDate.class, new LocalDateAdapter().nullSafe())
.setNumberToNumberStrategy(ToNumberPolicy.BIG_DECIMAL).registerTypeAdapter(Money.class, new MoneySerializer())
.addDeserializationExclusionStrategy(new JsonExcludeAnnotationBasedExclusionStrategy())
.addSerializationExclusionStrategy(new JsonExcludeAnnotationBasedExclusionStrategy()).create();
}
private Gson createDeserializer(LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail, MathContext mc,
Integer installmentAmountInMultipliesOf) {
InterestScheduleModelServiceGsonContext ctx = new InterestScheduleModelServiceGsonContext(
new MonetaryCurrency(loanProductRelatedDetail.getCurrencyData()), mc, loanProductRelatedDetail,
installmentAmountInMultipliesOf);
return new GsonBuilder().registerTypeAdapter(LocalDate.class, new LocalDateAdapter().nullSafe())
.setNumberToNumberStrategy(ToNumberPolicy.BIG_DECIMAL)
.registerTypeAdapter(Money.class, new MoneyDeserializer(ctx.getMc(), ctx.getCurrency()))
.registerTypeAdapter(InterestPeriod.class, (InstanceCreator<InterestPeriod>) ctx::createInterestPeriodInstance)
.registerTypeAdapter(ProgressiveLoanInterestScheduleModel.class,
(InstanceCreator<ProgressiveLoanInterestScheduleModel>) ctx::createProgressiveLoanInterestScheduleModelInstance)
.registerTypeAdapter(RepaymentPeriod.class, (InstanceCreator<RepaymentPeriod>) ctx::createRepaymentPeriodInstance)
.addDeserializationExclusionStrategy(new JsonExcludeAnnotationBasedExclusionStrategy())
.addSerializationExclusionStrategy(new JsonExcludeAnnotationBasedExclusionStrategy()).create();
}
@Override
public String toJson(@NotNull ProgressiveLoanInterestScheduleModel model) {
return gsonSerializer.toJson(model);
}
@Override
public ProgressiveLoanInterestScheduleModel fromJson(String s,
@NotNull LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail, @NotNull MathContext mc,
Integer installmentAmountInMultipliesOf) {
if (s == null) {
return null;
}
try {
Gson gson = createDeserializer(loanProductRelatedDetail, mc, installmentAmountInMultipliesOf);
return gson.fromJson(s, ProgressiveLoanInterestScheduleModel.class);
} catch (Exception e) {
log.warn("Failed to parse ProgressiveLoanInterestScheduleModel json. Falling back to default value.", e);
return null;
}
}
}
@@ -29,6 +29,7 @@ import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
@@ -39,6 +40,7 @@ import org.apache.fineract.organisation.monetary.domain.Money;
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
public class InterestPeriod implements Comparable<InterestPeriod> { public class InterestPeriod implements Comparable<InterestPeriod> {
@JsonExclude
private final RepaymentPeriod repaymentPeriod; private final RepaymentPeriod repaymentPeriod;
@Setter @Setter
@NotNull @NotNull
@@ -58,9 +60,21 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
private Money balanceCorrectionAmount; private Money balanceCorrectionAmount;
private Money outstandingLoanBalance; private Money outstandingLoanBalance;
@JsonExclude
private final MathContext mc; private final MathContext mc;
private final boolean isPaused; private final boolean isPaused;
public static InterestPeriod copy(@NotNull RepaymentPeriod repaymentPeriod, @NotNull InterestPeriod interestPeriod, MathContext mc) {
return new InterestPeriod(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(),
interestPeriod.getRateFactor(), interestPeriod.getRateFactorTillPeriodDueDate(), interestPeriod.getChargebackPrincipal(),
interestPeriod.getChargebackInterest(), interestPeriod.getDisbursementAmount(), interestPeriod.getBalanceCorrectionAmount(),
interestPeriod.getOutstandingLoanBalance(), mc, interestPeriod.isPaused());
}
public static InterestPeriod empty(@NotNull RepaymentPeriod repaymentPeriod, MathContext mc) {
return new InterestPeriod(repaymentPeriod, null, null, null, null, null, null, null, null, null, mc, false);
}
public static InterestPeriod copy(@NotNull RepaymentPeriod repaymentPeriod, @NotNull InterestPeriod interestPeriod) { public static InterestPeriod copy(@NotNull RepaymentPeriod repaymentPeriod, @NotNull InterestPeriod interestPeriod) {
return new InterestPeriod(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(), return new InterestPeriod(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(),
interestPeriod.getRateFactor(), interestPeriod.getRateFactorTillPeriodDueDate(), interestPeriod.getChargebackPrincipal(), interestPeriod.getRateFactor(), interestPeriod.getRateFactorTillPeriodDueDate(), interestPeriod.getChargebackPrincipal(),
@@ -39,8 +39,10 @@ import java.util.function.BiConsumer;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
@@ -50,14 +52,18 @@ import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaym
@Data @Data
@Accessors(fluent = true) @Accessors(fluent = true)
@AllArgsConstructor
public class ProgressiveLoanInterestScheduleModel { public class ProgressiveLoanInterestScheduleModel {
private final List<RepaymentPeriod> repaymentPeriods; private final List<RepaymentPeriod> repaymentPeriods;
private final TreeSet<InterestRate> interestRates; private final TreeSet<InterestRate> interestRates;
@JsonExclude
private final LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail; private final LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail;
private final Map<LoanTermVariationType, List<LoanTermVariationsData>> loanTermVariations; private final Map<LoanTermVariationType, List<LoanTermVariationsData>> loanTermVariations;
private final Integer installmentAmountInMultiplesOf; private final Integer installmentAmountInMultiplesOf;
@JsonExclude
private final MathContext mc; private final MathContext mc;
@JsonExclude
private final Money zero; private final Money zero;
private final Map<LoanInterestScheduleModelModifiers, Boolean> modifiers; private final Map<LoanInterestScheduleModelModifiers, Boolean> modifiers;
@@ -31,6 +31,7 @@ import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.util.Memo; import org.apache.fineract.portfolio.util.Memo;
@@ -39,6 +40,7 @@ import org.apache.fineract.portfolio.util.Memo;
@EqualsAndHashCode(exclude = { "previous" }) @EqualsAndHashCode(exclude = { "previous" })
public final class RepaymentPeriod { public final class RepaymentPeriod {
@JsonExclude
private final RepaymentPeriod previous; private final RepaymentPeriod previous;
@Getter @Getter
private final LocalDate fromDate; private final LocalDate fromDate;
@@ -59,11 +61,16 @@ public final class RepaymentPeriod {
@Getter @Getter
private Money paidInterest; private Money paidInterest;
@JsonExclude
private final MathContext mc; private final MathContext mc;
@JsonExclude
private Memo<BigDecimal> rateFactorPlus1Calculation; private Memo<BigDecimal> rateFactorPlus1Calculation;
@JsonExclude
private Memo<Money> calculatedDueInterestCalculation; private Memo<Money> calculatedDueInterestCalculation;
@JsonExclude
private Memo<Money> dueInterestCalculation; private Memo<Money> dueInterestCalculation;
@JsonExclude
private Memo<Money> outstandingBalanceCalculation; private Memo<Money> outstandingBalanceCalculation;
private RepaymentPeriod(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, List<InterestPeriod> interestPeriods, private RepaymentPeriod(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, List<InterestPeriod> interestPeriods,
@@ -79,6 +86,10 @@ public final class RepaymentPeriod {
this.mc = mc; this.mc = mc;
} }
public static RepaymentPeriod empty(RepaymentPeriod previous, MathContext mc) {
return new RepaymentPeriod(previous, null, null, new ArrayList<>(), null, null, null, null, mc);
}
public static RepaymentPeriod create(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, Money emi, MathContext mc) { public static RepaymentPeriod create(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, Money emi, MathContext mc) {
final Money zero = emi.zero(); final Money zero = emi.zero();
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, fromDate, dueDate, new ArrayList<>(), emi, emi, zero, zero, final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, fromDate, dueDate, new ArrayList<>(), emi, emi, zero, zero,
@@ -94,7 +105,7 @@ public final class RepaymentPeriod {
repaymentPeriod.paidInterest, mc); repaymentPeriod.paidInterest, mc);
// There is always at least 1 interest period, by default with same from-due date as repayment period // There is always at least 1 interest period, by default with same from-due date as repayment period
for (InterestPeriod interestPeriod : repaymentPeriod.interestPeriods) { for (InterestPeriod interestPeriod : repaymentPeriod.interestPeriods) {
newRepaymentPeriod.interestPeriods.add(InterestPeriod.copy(newRepaymentPeriod, interestPeriod)); newRepaymentPeriod.interestPeriods.add(InterestPeriod.copy(newRepaymentPeriod, interestPeriod, mc));
} }
return newRepaymentPeriod; return newRepaymentPeriod;
} }
@@ -23,4 +23,5 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd"> xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Sequence is starting from 5000 to make it easier to move existing liquibase changesets here --> <!-- Sequence is starting from 5000 to make it easier to move existing liquibase changesets here -->
<include file="parts/5001_create_progressive_loan_model.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.31.xsd"
objectQuotingStrategy="QUOTE_ONLY_RESERVED_WORDS">
<changeSet id="1744116182-1" author="fineract">
<createTable tableName="m_loan_progressive_model">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true" primaryKeyName="pk_m_loan_progressive_model"/>
</column>
<column name="version" type="INT">
<constraints nullable="false"/>
</column>
<column name="loan_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="json_model" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="business_date" type="DATE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="1744116182-2" author="fineract">
<addUniqueConstraint columnNames="loan_id" constraintName="uc_m_loan_progressive_model_loan"
tableName="m_loan_progressive_model"/>
</changeSet>
<changeSet id="1744116182-3" author="fineract">
<addForeignKeyConstraint baseColumnNames="loan_id" baseTableName="m_loan_progressive_model"
constraintName="FK_M_LOAN_PROGRESSIVE_MODEL_ON_LOAN" referencedColumnNames="id"
referencedTableName="m_loan"/>
</changeSet>
<changeSet author="fineract" id="1744116182-4-my" context="mysql">
<addColumn tableName="m_loan_progressive_model">
<column name="last_modified_on_utc" type="DATETIME(6)">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="1744116182
-4-pg" context="postgresql">
<addColumn tableName="m_loan_progressive_model">
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -37,6 +37,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTermVariationType; import org.apache.fineract.portfolio.loanaccount.domain.LoanTermVariationType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelRepaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanInterestScheduleModelParserServiceGsonImpl;
import org.apache.fineract.portfolio.loanproduct.calc.data.InterestPeriod; import org.apache.fineract.portfolio.loanproduct.calc.data.InterestPeriod;
import org.apache.fineract.portfolio.loanproduct.calc.data.PeriodDueDetails; import org.apache.fineract.portfolio.loanproduct.calc.data.PeriodDueDetails;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel; import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
@@ -70,6 +71,7 @@ class ProgressiveEMICalculatorTest {
private static List<LoanRepaymentScheduleInstallment> periods; private static List<LoanRepaymentScheduleInstallment> periods;
private final BigDecimal interestRate = BigDecimal.valueOf(0.094822); private final BigDecimal interestRate = BigDecimal.valueOf(0.094822);
private final ProgressiveLoanInterestScheduleModelParserServiceGsonImpl interestScheduleModelService = new ProgressiveLoanInterestScheduleModelParserServiceGsonImpl();
@BeforeAll @BeforeAll
public static void init() { public static void init() {
@@ -3114,6 +3116,79 @@ class ProgressiveEMICalculatorTest {
checkPeriod(interestSchedule, 5, 0, 17.05, 0.005833333333, 0.0988749999945, 0.10, 16.95, 0.0); checkPeriod(interestSchedule, 5, 0, 17.05, 0.005833333333, 0.0988749999945, 0.10, 16.95, 0.0);
} }
@Test
public void test_interest_schedule_model_service_serialization() {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = List.of(
repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)),
repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)),
repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)),
repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)),
repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)),
repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1)));
final BigDecimal interestRate = BigDecimal.valueOf(7.0);
final Integer installmentAmountInMultiplesOf = null;
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency);
final ProgressiveLoanInterestScheduleModel interestScheduleExpected = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, List.of(), installmentAmountInMultiplesOf, mc);
final Money disbursedAmount = toMoney(100.0);
ProgressiveLoanInterestScheduleModel interestScheduleActual = copyJson(interestScheduleExpected);
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
emiCalculator.addDisbursement(interestScheduleExpected, LocalDate.of(2024, 1, 1), disbursedAmount);
emiCalculator.addDisbursement(interestScheduleActual, LocalDate.of(2024, 1, 1), disbursedAmount);
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
interestScheduleActual = copyJson(interestScheduleExpected);
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
// repay 1st period
LocalDate txnDate = LocalDate.of(2024, 2, 1);
emiCalculator.payPrincipal(interestScheduleExpected, txnDate, txnDate, toMoney(16.43));
emiCalculator.payInterest(interestScheduleExpected, txnDate, txnDate, toMoney(0.58));
emiCalculator.payPrincipal(interestScheduleActual, txnDate, txnDate, toMoney(16.43));
emiCalculator.payInterest(interestScheduleActual, txnDate, txnDate, toMoney(0.58));
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
interestScheduleActual = copyJson(interestScheduleExpected);
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
// repay 2nd period
txnDate = LocalDate.of(2024, 3, 1);
emiCalculator.payPrincipal(interestScheduleExpected, txnDate, txnDate, toMoney(16.52));
emiCalculator.payInterest(interestScheduleExpected, txnDate, txnDate, toMoney(0.49));
emiCalculator.payPrincipal(interestScheduleActual, txnDate, txnDate, toMoney(16.52));
emiCalculator.payInterest(interestScheduleActual, txnDate, txnDate, toMoney(0.49));
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
interestScheduleActual = copyJson(interestScheduleExpected);
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
// chargeback
txnDate = LocalDate.of(2024, 3, 15);
emiCalculator.chargebackPrincipal(interestScheduleExpected, txnDate, toMoney(16.52));
emiCalculator.chargebackInterest(interestScheduleExpected, txnDate, toMoney(0.49));
emiCalculator.chargebackPrincipal(interestScheduleActual, txnDate, toMoney(16.52));
emiCalculator.chargebackInterest(interestScheduleActual, txnDate, toMoney(0.49));
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
interestScheduleActual = copyJson(interestScheduleExpected);
verifyAllPeriods(interestScheduleExpected, interestScheduleActual);
PeriodDueDetails dueAmountsExpected = emiCalculator.getDueAmounts(interestScheduleExpected, LocalDate.of(2024, 4, 1), txnDate);
PeriodDueDetails dueAmountsActual = emiCalculator.getDueAmounts(interestScheduleActual, LocalDate.of(2024, 4, 1), txnDate);
Assertions.assertEquals(toDouble(dueAmountsExpected.getDuePrincipal()), toDouble(dueAmountsActual.getDuePrincipal()));
Assertions.assertEquals(toDouble(dueAmountsExpected.getDueInterest()), toDouble(dueAmountsActual.getDueInterest()));
}
private static LoanScheduleModelRepaymentPeriod repayment(int periodNumber, LocalDate fromDate, LocalDate dueDate) { private static LoanScheduleModelRepaymentPeriod repayment(int periodNumber, LocalDate fromDate, LocalDate dueDate) {
final Money zeroAmount = Money.zero(currency); final Money zeroAmount = Money.zero(currency);
return LoanScheduleModelRepaymentPeriod.repayment(periodNumber, fromDate, dueDate, zeroAmount, zeroAmount, zeroAmount, zeroAmount, return LoanScheduleModelRepaymentPeriod.repayment(periodNumber, fromDate, dueDate, zeroAmount, zeroAmount, zeroAmount, zeroAmount,
@@ -3202,4 +3277,24 @@ class ProgressiveEMICalculatorTest {
private static Money toMoney(final double value) { private static Money toMoney(final double value) {
return Money.of(currency, BigDecimal.valueOf(value)); return Money.of(currency, BigDecimal.valueOf(value));
} }
private static void verifyAllPeriods(final ProgressiveLoanInterestScheduleModel expectedModel,
final ProgressiveLoanInterestScheduleModel actualModel) {
for (int repInd = 0; repInd < expectedModel.repaymentPeriods().size(); repInd++) {
RepaymentPeriod repaymentPeriod = expectedModel.repaymentPeriods().get(repInd);
for (int interestPeriodIndex = 0; interestPeriodIndex < repaymentPeriod.getInterestPeriods().size(); interestPeriodIndex++) {
final InterestPeriod interestPeriod = repaymentPeriod.getInterestPeriods().get(interestPeriodIndex);
checkPeriod(actualModel, repInd, interestPeriodIndex, toDouble(repaymentPeriod.getEmi()),
toDouble(applyMathContext(interestPeriod.getRateFactor())), toDouble(interestPeriod.getCalculatedDueInterest()),
toDouble(repaymentPeriod.getDueInterest()), toDouble(repaymentPeriod.getDuePrincipal()),
toDouble(repaymentPeriod.getOutstandingLoanBalance()));
}
}
}
private ProgressiveLoanInterestScheduleModel copyJson(ProgressiveLoanInterestScheduleModel toCopy) {
String json = interestScheduleModelService.toJson(toCopy);
return interestScheduleModelService.fromJson(json, toCopy.loanProductRelatedDetail(), toCopy.mc(),
toCopy.installmentAmountInMultiplesOf());
}
} }
@@ -35,6 +35,7 @@
<include file="db/changelog/tenant/module/loan/module-changelog-master.xml" context="tenant_db AND !initial_switch"/> <include file="db/changelog/tenant/module/loan/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<include file="db/changelog/tenant/module/investor/module-changelog-master.xml" context="tenant_db AND !initial_switch"/> <include file="db/changelog/tenant/module/investor/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<includeAll path="db/custom-changelog" errorIfMissingOrEmpty="false" context="tenant_db AND !initial_switch AND custom_changelog"/> <includeAll path="db/custom-changelog" errorIfMissingOrEmpty="false" context="tenant_db AND !initial_switch AND custom_changelog"/>
<include file="/db/changelog/tenant/module/progressiveloan/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<!-- Scripts to run after the modules were initialized --> <!-- Scripts to run after the modules were initialized -->
<include file="tenant/final-changelog-tenant.xml" relativeToChangelogFile="true" context="tenant_db AND !initial_switch"/> <include file="tenant/final-changelog-tenant.xml" relativeToChangelogFile="true" context="tenant_db AND !initial_switch"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,54 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.integrationtests;
import org.apache.fineract.client.models.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.junit.jupiter.api.Test;
public class ProgressiveLoanModelIntegrationTest extends BaseLoanIntegrationTest {
private final Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
private final Long loanProductId = loanProductHelper.createLoanProduct(create4IProgressive().isInterestRecalculationEnabled(true))
.getResourceId();
@Test
public void testModelReturnsNullAndThenSaveThenNotNull() {
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(clientId, loanProductId, "1 January 2024", 1000.0, 96.32, 6, null);
loanTransactionHelper.disburseLoan(loanId, "1 January 2024", 1000.0);
// Model not saved, fetching it. It should return null
ProgressiveLoanInterestScheduleModel progressiveLoanInterestScheduleModelResponse1 = ok(
fineractClient().progressiveLoanApi.fetchModel(loanId));
assertThat(progressiveLoanInterestScheduleModelResponse1).isNull();
// Forcing Model recalculation and save to database. It should return the actual model.
ProgressiveLoanInterestScheduleModel ok = ok(fineractClient().progressiveLoanApi.updateModel(loanId));
assertThat(ok).isNotNull();
// Model saved in previous step. API should return the previous model.
ProgressiveLoanInterestScheduleModel progressiveLoanInterestScheduleModelResponse2 = ok(
fineractClient().progressiveLoanApi.fetchModel(loanId));
assertThat(progressiveLoanInterestScheduleModelResponse2).isNotNull();
});
}
}