From 6602734ac18a6f26abdf5c55b6c94959328a1142 Mon Sep 17 00:00:00 2001 From: "mark.vituska" Date: Wed, 17 Jun 2026 18:10:31 +0200 Subject: [PATCH] FINERACT-2455: Implement near breach configuration update --- .../service/CommandWrapperBuilder.java | 8 + .../NearBreachEvaluationBusinessStep.java | 3 +- .../WorkingCapitalLoanConstants.java | 5 + .../api/WorkingCapitalLoanApiResource.java | 63 +++ .../WorkingCapitalLoanApiResourceSwagger.java | 22 + ...orkingCapitalLoanNearBreachChangeData.java | 29 ++ .../WorkingCapitalLoanNearBreachChange.java | 101 ++++ ...reachWorkingCapitalLoanCommandHandler.java | 42 ++ ...CapitalLoanNearBreachChangeRepository.java | 32 ++ .../WorkingCapitalLoanDataValidator.java | 59 +++ ...apitalLoanNearBreachChangeReadService.java | 27 ++ ...alLoanNearBreachChangeReadServiceImpl.java | 54 +++ ...alLoanNearBreachEvaluationServiceImpl.java | 24 +- ...orkingCapitalLoanWritePlatformService.java | 2 + ...ngCapitalLoanWritePlatformServiceImpl.java | 92 ++++ ...rkingCapitalLoanProductRelatedDetails.java | 10 + .../module-changelog-master.xml | 2 + .../parts/0044_wc_loan_near_breach_change.xml | 131 ++++++ ...5_wc_loan_near_breach_override_columns.xml | 40 ++ ...orkingCapitalLoanNearBreachConfigTest.java | 442 ++++++++++++++++++ .../WorkingCapitalLoanRequestBuilders.java | 13 + .../WorkingCapitalLoanHelper.java | 37 ++ 22 files changed, 1228 insertions(+), 10 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanNearBreachChangeData.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanNearBreachChange.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UpdateNearBreachWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanNearBreachChangeRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadServiceImpl.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_near_breach_change.xml create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_near_breach_override_columns.xml create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanNearBreachConfigTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index f4a0dc6e19d..4af42ff88d9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -914,6 +914,14 @@ public CommandWrapperBuilder updatePeriodPaymentRateWorkingCapitalLoanApplicatio return this; } + public CommandWrapperBuilder updateNearBreachConfigWorkingCapitalLoan(final Long loanId) { + this.actionName = "UPDATENEARBREACH"; + this.entityName = "WORKINGCAPITALLOAN"; + this.entityId = loanId; + this.href = "/working-capital-loans/" + loanId + "/near-breach-config"; + return this; + } + public CommandWrapperBuilder createClientIdentifier(final Long clientId) { this.actionName = ACTION_CREATE; this.entityName = ENTITY_CLIENTIDENTIFIER; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/NearBreachEvaluationBusinessStep.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/NearBreachEvaluationBusinessStep.java index f0ccd687a3b..02991f05a5c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/NearBreachEvaluationBusinessStep.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/NearBreachEvaluationBusinessStep.java @@ -42,7 +42,8 @@ public WorkingCapitalLoan execute(final WorkingCapitalLoan loan) { } final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); - if (details == null || details.getNearBreach() == null) { + final boolean hasConfig = details != null && (details.getNearBreach() != null || details.getNearBreachThresholdOverride() != null); + if (!hasConfig) { log.debug("Skipping near breach evaluation for WC loan {} - no near breach configuration", loan.getId()); return loan; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java index 7e6096d7de0..294f6ce49a6 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java @@ -94,4 +94,9 @@ private WorkingCapitalLoanConstants() { // Period payment rate change parameters public static final String periodPaymentRateParamName = "periodPaymentRate"; public static final String previousPeriodPaymentRateParamName = "previousRate"; + + // Near breach change parameters + public static final String nearBreachThresholdParamName = "nearBreachThreshold"; + public static final String nearBreachFrequencyParamName = "nearBreachFrequency"; + public static final String nearBreachFrequencyTypeParamName = "nearBreachFrequencyType"; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java index 7e01beba520..a04e7ee69d6 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java @@ -54,11 +54,13 @@ import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyTagHistoryData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanNearBreachChangeData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanPeriodPaymentRateChangeData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTemplateData; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanApplicationReadPlatformService; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanDelinquencyReadPlatformService; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanNearBreachChangeReadService; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanPeriodPaymentRateChangeReadService; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -77,6 +79,7 @@ public class WorkingCapitalLoanApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final WorkingCapitalLoanDelinquencyReadPlatformService workingCapitalLoanDelinquencyReadPlatformService; private final WorkingCapitalLoanPeriodPaymentRateChangeReadService rateChangeReadService; + private final WorkingCapitalLoanNearBreachChangeReadService nearBreachChangeReadService; @GET @Path("template") @@ -409,4 +412,64 @@ public List getRateChangeHistoryB } return this.rateChangeReadService.retrieveRateChangeHistory(resolvedLoanId); } + + @PUT + @Path("{loanId}/near-breach-config") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "updateWorkingCapitalLoanNearBreachConfigById", summary = "Update near breach configuration for an active Working Capital Loan", description = "Overrides the near breach threshold and frequency for the loan. Applies only to future evaluation periods; history is preserved.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PutWorkingCapitalLoansLoanIdNearBreachConfigRequest.class))) + public CommandProcessingResult updateNearBreachConfigById( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return updateNearBreachConfig(loanId, null, apiRequestBodyAsJson); + } + + @PUT + @Path("external-id/{loanExternalId}/near-breach-config") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "updateWorkingCapitalLoanNearBreachConfigByExternalId", summary = "Update near breach configuration for an active Working Capital Loan by external id", description = "Overrides the near breach threshold and frequency for the loan. Applies only to future evaluation periods; history is preserved.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PutWorkingCapitalLoansLoanIdNearBreachConfigRequest.class))) + public CommandProcessingResult updateNearBreachConfigByExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return updateNearBreachConfig(null, loanExternalId, apiRequestBodyAsJson); + } + + private CommandProcessingResult updateNearBreachConfig(final Long loanId, final String loanExternalIdStr, + final String apiRequestBodyAsJson) { + final Long resolvedLoanId = loanId != null ? loanId + : readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalIdStr)); + if (resolvedLoanId == null) { + throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalIdStr)); + } + final CommandWrapper commandRequest = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson) + .updateNearBreachConfigWorkingCapitalLoan(resolvedLoanId).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @GET + @Path("{loanId}/near-breach-changes") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "getWorkingCapitalLoanNearBreachChangeHistoryById", summary = "Retrieve near breach configuration change history for a Working Capital Loan", description = "Returns all near breach configuration change records for the loan, ordered by most recent first.") + public List getNearBreachChangeHistoryById( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.nearBreachChangeReadService.retrieveNearBreachChangeHistory(loanId); + } + + @GET + @Path("external-id/{loanExternalId}/near-breach-changes") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "getWorkingCapitalLoanNearBreachChangeHistoryByExternalId", summary = "Retrieve near breach configuration change history for a Working Capital Loan by external id", description = "Returns all near breach configuration change records for the loan, ordered by most recent first.") + public List getNearBreachChangeHistoryByExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final Long resolvedLoanId = readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalId)); + if (resolvedLoanId == null) { + throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalId)); + } + return this.nearBreachChangeReadService.retrieveNearBreachChangeHistory(resolvedLoanId); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java index 439e406d568..fd96541bd39 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java @@ -667,4 +667,26 @@ private PutWorkingCapitalLoansLoanIdRateRequest() {} public String locale; } + @Schema(description = "Request for updating near breach configuration on an active Working Capital Loan") + public static final class PutWorkingCapitalLoansLoanIdNearBreachConfigRequest { + + private PutWorkingCapitalLoansLoanIdNearBreachConfigRequest() {} + + @Schema(example = "40.0", requiredMode = Schema.RequiredMode.REQUIRED, description = "New near breach threshold percentage (must be > 0 and <= 100)") + public BigDecimal nearBreachThreshold; + + @Schema(example = "7", requiredMode = Schema.RequiredMode.REQUIRED, description = "New near breach frequency (must be > 0)") + public Integer nearBreachFrequency; + + @Schema(example = "DAYS", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = { "DAYS", "WEEKS", + "MONTHS" }, description = "New near breach frequency type") + public String nearBreachFrequencyType; + + @Schema(example = "Near breach config change note", description = "Optional note (max 1000 characters)") + public String note; + + @Schema(example = "en_GB") + public String locale; + } + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanNearBreachChangeData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanNearBreachChangeData.java new file mode 100644 index 00000000000..90f5beba7d1 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanNearBreachChangeData.java @@ -0,0 +1,29 @@ +/** + * 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.workingcapitalloan.data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; + +public record WorkingCapitalLoanNearBreachChangeData(Long id, Long loanId, LocalDate effectiveDate, BigDecimal previousThreshold, + BigDecimal newThreshold, Integer previousFrequency, Integer newFrequency, String previousFrequencyType, String newFrequencyType, + boolean reversed, LocalDate reversedOnDate, OffsetDateTime createdDate) { + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanNearBreachChange.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanNearBreachChange.java new file mode 100644 index 00000000000..bca39e2a75f --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanNearBreachChange.java @@ -0,0 +1,101 @@ +/** + * 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.workingcapitalloan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "m_wc_loan_near_breach_change") +public class WorkingCapitalLoanNearBreachChange extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "wc_loan_id", nullable = false) + private WorkingCapitalLoan workingCapitalLoan; + + @Column(name = "effective_date", nullable = false) + private LocalDate effectiveDate; + + @Column(name = "previous_threshold", scale = 6, precision = 19, nullable = false) + private BigDecimal previousThreshold; + + @Column(name = "new_threshold", scale = 6, precision = 19, nullable = false) + private BigDecimal newThreshold; + + @Column(name = "previous_frequency", nullable = false) + private Integer previousFrequency; + + @Column(name = "new_frequency", nullable = false) + private Integer newFrequency; + + @Enumerated(EnumType.STRING) + @Column(name = "previous_frequency_type", nullable = false, length = 50) + private WorkingCapitalLoanPeriodFrequencyType previousFrequencyType; + + @Enumerated(EnumType.STRING) + @Column(name = "new_frequency_type", nullable = false, length = 50) + private WorkingCapitalLoanPeriodFrequencyType newFrequencyType; + + @Column(name = "is_reversed", nullable = false) + private boolean reversed; + + @Column(name = "reversed_on_date") + private LocalDate reversedOnDate; + + @Version + private int version; + + public static WorkingCapitalLoanNearBreachChange create(final WorkingCapitalLoan loan, final LocalDate effectiveDate, + final BigDecimal previousThreshold, final BigDecimal newThreshold, final Integer previousFrequency, final Integer newFrequency, + final WorkingCapitalLoanPeriodFrequencyType previousFrequencyType, + final WorkingCapitalLoanPeriodFrequencyType newFrequencyType) { + final WorkingCapitalLoanNearBreachChange change = new WorkingCapitalLoanNearBreachChange(); + change.workingCapitalLoan = loan; + change.effectiveDate = effectiveDate; + change.previousThreshold = previousThreshold; + change.newThreshold = newThreshold; + change.previousFrequency = previousFrequency; + change.newFrequency = newFrequency; + change.previousFrequencyType = previousFrequencyType; + change.newFrequencyType = newFrequencyType; + change.reversed = false; + return change; + } + + public void reverse(final LocalDate reversalDate) { + this.reversed = true; + this.reversedOnDate = reversalDate; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UpdateNearBreachWorkingCapitalLoanCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UpdateNearBreachWorkingCapitalLoanCommandHandler.java new file mode 100644 index 00000000000..fd31030152d --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UpdateNearBreachWorkingCapitalLoanCommandHandler.java @@ -0,0 +1,42 @@ +/** + * 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.workingcapitalloan.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = "WORKINGCAPITALLOAN", action = "UPDATENEARBREACH") +public class UpdateNearBreachWorkingCapitalLoanCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.updateNearBreachConfig(command.entityId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanNearBreachChangeRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanNearBreachChangeRepository.java new file mode 100644 index 00000000000..3576f739836 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanNearBreachChangeRepository.java @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.repository; + +import java.util.List; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNearBreachChange; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WorkingCapitalLoanNearBreachChangeRepository extends JpaRepository { + + List findByWorkingCapitalLoanIdOrderByCreatedDateDesc(Long loanId); + + List findByWorkingCapitalLoanIdAndReversedFalse(Long loanId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index 3a0956369fa..be2a1efdb8d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -48,6 +48,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; @@ -107,6 +108,11 @@ public class WorkingCapitalLoanDataValidator { Arrays.asList(WorkingCapitalLoanConstants.localeParameterName, WorkingCapitalLoanConstants.periodPaymentRateParamName, WorkingCapitalLoanConstants.noteParamName)); + private static final Set UPDATE_NEAR_BREACH_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList(WorkingCapitalLoanConstants.localeParameterName, WorkingCapitalLoanConstants.nearBreachThresholdParamName, + WorkingCapitalLoanConstants.nearBreachFrequencyParamName, WorkingCapitalLoanConstants.nearBreachFrequencyTypeParamName, + WorkingCapitalLoanConstants.noteParamName)); + private static final int NOTE_MAX_LENGTH = 1000; private static final int EXTERNAL_ID_MAX_LENGTH = 100; private static final int PAYMENT_DETAIL_STRING_MAX_LENGTH = 50; @@ -771,6 +777,59 @@ public void validateCreditBalanceRefund(final String json, final WorkingCapitalL throwExceptionIfValidationWarningsExist(dataValidationErrors); } + public void validateUpdateNearBreachConfig(final String json, final WorkingCapitalLoan loan) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UPDATE_NEAR_BREACH_SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + final JsonElement element = this.fromApiJsonHelper.parse(json); + + if (loan.getLoanStatus() != LoanStatus.ACTIVE) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("near.breach.config.change.not.allowed.for.non.active.loan"); + } + + if (loan.getLoanProductRelatedDetails().getNearBreach() == null) { + baseDataValidator.reset() + .failWithCodeNoParameterAddedToErrorCode("near.breach.config.change.not.allowed.loan.has.no.near.breach.configuration"); + } + + final BigDecimal threshold = this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.nearBreachThresholdParamName, + element, new HashSet<>()); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.nearBreachThresholdParamName).value(threshold).notNull() + .positiveAmount(); + if (threshold != null && threshold.compareTo(BigDecimal.valueOf(100)) > 0) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.nearBreachThresholdParamName) + .failWithCode("must.not.exceed.100.percent"); + } + + final Integer frequency = this.fromApiJsonHelper + .extractIntegerSansLocaleNamed(WorkingCapitalLoanConstants.nearBreachFrequencyParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.nearBreachFrequencyParamName).value(frequency).notNull() + .integerGreaterThanZero(); + + final String frequencyTypeStr = this.fromApiJsonHelper + .extractStringNamed(WorkingCapitalLoanConstants.nearBreachFrequencyTypeParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.nearBreachFrequencyTypeParamName).value(frequencyTypeStr) + .notBlank(); + if (StringUtils.isNotBlank(frequencyTypeStr) && WorkingCapitalLoanPeriodFrequencyType.fromString(frequencyTypeStr) == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.nearBreachFrequencyTypeParamName) + .failWithCode("invalid.frequency.type"); + } + + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(NOTE_MAX_LENGTH); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + public void validateUpdatePeriodPaymentRate(final String json, final WorkingCapitalLoan loan) { if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadService.java new file mode 100644 index 00000000000..c554b514854 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadService.java @@ -0,0 +1,27 @@ +/** + * 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.workingcapitalloan.service; + +import java.util.List; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanNearBreachChangeData; + +public interface WorkingCapitalLoanNearBreachChangeReadService { + + List retrieveNearBreachChangeHistory(Long loanId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadServiceImpl.java new file mode 100644 index 00000000000..de2511938e4 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachChangeReadServiceImpl.java @@ -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.portfolio.workingcapitalloan.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanNearBreachChangeData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNearBreachChange; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNearBreachChangeRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WorkingCapitalLoanNearBreachChangeReadServiceImpl implements WorkingCapitalLoanNearBreachChangeReadService { + + private final WorkingCapitalLoanNearBreachChangeRepository repository; + private final WorkingCapitalLoanRepository loanRepository; + + @Override + public List retrieveNearBreachChangeHistory(final Long loanId) { + if (!loanRepository.existsById(loanId)) { + throw new WorkingCapitalLoanNotFoundException(loanId); + } + return repository.findByWorkingCapitalLoanIdOrderByCreatedDateDesc(loanId).stream().map(e -> toData(e, loanId)).toList(); + } + + private WorkingCapitalLoanNearBreachChangeData toData(final WorkingCapitalLoanNearBreachChange entity, final Long loanId) { + return new WorkingCapitalLoanNearBreachChangeData(entity.getId(), loanId, entity.getEffectiveDate(), entity.getPreviousThreshold(), + entity.getNewThreshold(), entity.getPreviousFrequency(), entity.getNewFrequency(), + entity.getPreviousFrequencyType() != null ? entity.getPreviousFrequencyType().name() : null, + entity.getNewFrequencyType() != null ? entity.getNewFrequencyType().name() : null, entity.isReversed(), + entity.getReversedOnDate(), entity.getCreatedDate().orElse(null)); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationServiceImpl.java index 549096d43af..83a76bde1a2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanNearBreachEvaluationServiceImpl.java @@ -32,7 +32,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; -import org.apache.fineract.portfolio.workingcapitalloannearbreach.domain.WorkingCapitalNearBreach; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; import org.springframework.stereotype.Service; @RequiredArgsConstructor @@ -53,27 +53,33 @@ public void evaluateNearBreach(final WorkingCapitalLoan loan, final LocalDate ef if (period.getNearBreach() != null) { return; } - final WorkingCapitalNearBreach config = loan.getLoanProductRelatedDetails().getNearBreach(); - if (evaluatePeriod(loan.getId(), period, config, effectiveDate)) { + final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); + final BigDecimal effectiveThreshold = details.getNearBreachThresholdOverride() != null ? details.getNearBreachThresholdOverride() + : details.getNearBreach().getThreshold(); + final Integer effectiveFrequency = details.getNearBreachFrequencyOverride() != null ? details.getNearBreachFrequencyOverride() + : details.getNearBreach().getFrequency(); + final WorkingCapitalLoanPeriodFrequencyType effectiveFrequencyType = details.getNearBreachFrequencyTypeOverride() != null + ? details.getNearBreachFrequencyTypeOverride() + : details.getNearBreach().getFrequencyType(); + if (evaluatePeriod(loan.getId(), period, effectiveThreshold, effectiveFrequency, effectiveFrequencyType, effectiveDate)) { breachScheduleRepository.saveAndFlush(period); } } - private boolean evaluatePeriod(final Long loanId, final WorkingCapitalLoanBreachSchedule period, final WorkingCapitalNearBreach config, - final LocalDate effectiveDate) { + private boolean evaluatePeriod(final Long loanId, final WorkingCapitalLoanBreachSchedule period, final BigDecimal threshold, + final Integer frequency, final WorkingCapitalLoanPeriodFrequencyType frequencyType, final LocalDate effectiveDate) { if (period.getMinPaymentAmount().compareTo(BigDecimal.ZERO) == 0) { return false; } - final LocalDate firstEvalDate = addFrequency(period.getFromDate(), config.getFrequency(), config.getFrequencyType()); + final LocalDate firstEvalDate = addFrequency(period.getFromDate(), frequency, frequencyType); if (firstEvalDate.isAfter(period.getToDate())) { return false; } - final List evalDates = listEvalDates(period.getFromDate(), period.getToDate(), config.getFrequency(), - config.getFrequencyType()); + final List evalDates = listEvalDates(period.getFromDate(), period.getToDate(), frequency, frequencyType); final int evalIndex = evalDates.indexOf(effectiveDate); if (evalIndex >= 0) { final MonetaryCurrency currency = period.getLoan().getLoanProductRelatedDetails().getCurrency(); - final BigDecimal thresholdFraction = config.getThreshold().divide(BigDecimal.valueOf(100), MoneyHelper.getMathContext()); + final BigDecimal thresholdFraction = threshold.divide(BigDecimal.valueOf(100), MoneyHelper.getMathContext()); final Money requiredCumulative = calculateRequiredCumulative(currency, period.getMinPaymentAmount(), thresholdFraction, evalIndex); final Money paidCumulative = Money.of(currency, period.getPaidAmount()); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java index 50c141984c5..92d6feeb961 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java @@ -46,4 +46,6 @@ public interface WorkingCapitalLoanWritePlatformService { CommandProcessingResult makeGoodwillCredit(Long loanId, JsonCommand command); CommandProcessingResult updatePeriodPaymentRate(Long loanId, JsonCommand command); + + CommandProcessingResult updateNearBreachConfig(Long loanId, JsonCommand command); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index d450bf9261b..63eafc68c40 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -63,7 +63,9 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNearBreachChange; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodPaymentRateChange; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; @@ -71,12 +73,15 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNearBreachChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionAllocationRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanDataValidator; +import org.apache.fineract.portfolio.workingcapitalloannearbreach.domain.WorkingCapitalNearBreach; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -104,6 +109,8 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanAccountingProcessor accountingProcessor; private final WorkingCapitalLoanTransactionRelationRepository relationRepository; private final WorkingCapitalLoanPeriodPaymentRateChangeRepository rateChangeRepository; + private final WorkingCapitalLoanNearBreachChangeRepository nearBreachChangeRepository; + private final WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; @Override @@ -890,6 +897,91 @@ public CommandProcessingResult updatePeriodPaymentRate(final Long loanId, final .withLoanId(loanId).with(changes).build(); } + @Override + @Transactional + public CommandProcessingResult updateNearBreachConfig(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + + this.validator.validateUpdateNearBreachConfig(command.json(), loan); + + final BigDecimal newThreshold = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.nearBreachThresholdParamName, command.parsedJson(), new HashSet<>()); + final Integer newFrequency = this.fromApiJsonHelper.extractIntegerNamed(WorkingCapitalLoanConstants.nearBreachFrequencyParamName, + command.parsedJson(), new HashSet<>()); + final String newFrequencyTypeStr = this.fromApiJsonHelper + .extractStringNamed(WorkingCapitalLoanConstants.nearBreachFrequencyTypeParamName, command.parsedJson()); + final WorkingCapitalLoanPeriodFrequencyType newFrequencyType = newFrequencyTypeStr != null + ? WorkingCapitalLoanPeriodFrequencyType.fromString(newFrequencyTypeStr) + : null; + + final BigDecimal previousThreshold = resolveEffectiveThreshold(loan); + final Integer previousFrequency = resolveEffectiveFrequency(loan); + final WorkingCapitalLoanPeriodFrequencyType previousFrequencyType = resolveEffectiveFrequencyType(loan); + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + + final List activeChanges = this.nearBreachChangeRepository + .findByWorkingCapitalLoanIdAndReversedFalse(loanId); + for (final WorkingCapitalLoanNearBreachChange active : activeChanges) { + active.reverse(businessDate); + } + if (!activeChanges.isEmpty()) { + this.nearBreachChangeRepository.saveAll(activeChanges); + } + + loan.getLoanProductRelatedDetails().setNearBreachThresholdOverride(newThreshold); + loan.getLoanProductRelatedDetails().setNearBreachFrequencyOverride(newFrequency); + loan.getLoanProductRelatedDetails().setNearBreachFrequencyTypeOverride(newFrequencyType); + + final WorkingCapitalLoanNearBreachChange nearBreachChange = WorkingCapitalLoanNearBreachChange.create(loan, businessDate, + previousThreshold, newThreshold, previousFrequency, newFrequency, previousFrequencyType, newFrequencyType); + this.nearBreachChangeRepository.save(nearBreachChange); + + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + createNote(noteText, loan); + this.loanRepository.saveAndFlush(loan); + + final Map changes = new LinkedHashMap<>(); + changes.put(WorkingCapitalLoanConstants.nearBreachThresholdParamName, newThreshold); + changes.put(WorkingCapitalLoanConstants.nearBreachFrequencyParamName, newFrequency); + changes.put(WorkingCapitalLoanConstants.nearBreachFrequencyTypeParamName, newFrequencyTypeStr); + if (StringUtils.isNotBlank(noteText)) { + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId) + .withEntityExternalId(loan.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId()) + .withLoanId(loanId).with(changes).build(); + } + + private BigDecimal resolveEffectiveThreshold(final WorkingCapitalLoan loan) { + final BigDecimal override = loan.getLoanProductRelatedDetails().getNearBreachThresholdOverride(); + if (override != null) { + return override; + } + final WorkingCapitalNearBreach nearBreach = loan.getLoanProductRelatedDetails().getNearBreach(); + return nearBreach != null ? nearBreach.getThreshold() : null; + } + + private Integer resolveEffectiveFrequency(final WorkingCapitalLoan loan) { + final Integer override = loan.getLoanProductRelatedDetails().getNearBreachFrequencyOverride(); + if (override != null) { + return override; + } + final WorkingCapitalNearBreach nearBreach = loan.getLoanProductRelatedDetails().getNearBreach(); + return nearBreach != null ? nearBreach.getFrequency() : null; + } + + private WorkingCapitalLoanPeriodFrequencyType resolveEffectiveFrequencyType(final WorkingCapitalLoan loan) { + final WorkingCapitalLoanPeriodFrequencyType override = loan.getLoanProductRelatedDetails().getNearBreachFrequencyTypeOverride(); + if (override != null) { + return override; + } + final WorkingCapitalNearBreach nearBreach = loan.getLoanProductRelatedDetails().getNearBreach(); + return nearBreach != null ? nearBreach.getFrequencyType() : null; + } + @Override public CommandProcessingResult makeGoodwillCredit(Long loanId, JsonCommand command) { return makeRepaymentLikeTransaction(loanId, command, LoanTransactionType.GOODWILL_CREDIT); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java index b8560720604..27c63908364 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java @@ -89,6 +89,16 @@ public class WorkingCapitalLoanProductRelatedDetails { @JoinColumn(name = "near_breach_id") private WorkingCapitalNearBreach nearBreach; + @Column(name = "near_breach_threshold_override", scale = 6, precision = 19) + private BigDecimal nearBreachThresholdOverride; + + @Column(name = "near_breach_frequency_override") + private Integer nearBreachFrequencyOverride; + + @Enumerated(EnumType.STRING) + @Column(name = "near_breach_frequency_type_override", length = 50) + private WorkingCapitalLoanPeriodFrequencyType nearBreachFrequencyTypeOverride; + @Column(name = "delinquency_grace_days", nullable = false) private Integer delinquencyGraceDays = 0; diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 25190c291cb..caeaeda13fc 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -65,4 +65,6 @@ + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_near_breach_change.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_near_breach_change.xml new file mode 100644 index 00000000000..4b7152c176b --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_near_breach_change.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATENEARBREACH_WORKINGCAPITALLOAN'; + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_near_breach_override_columns.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_near_breach_override_columns.xml new file mode 100644 index 00000000000..732e3ee0833 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0045_wc_loan_near_breach_override_columns.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanNearBreachConfigTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanNearBreachConfigTest.java new file mode 100644 index 00000000000..c4cfcc239a2 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanNearBreachConfigTest.java @@ -0,0 +1,442 @@ +/** + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.feign.util.FeignCalls; +import org.apache.fineract.client.models.CommandProcessingResult; +import org.apache.fineract.client.models.InlineJobRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdNearBreachConfigRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdNearBreachConfigRequest.NearBreachFrequencyTypeEnum; +import org.apache.fineract.client.models.WorkingCapitalBreachRequest; +import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData; +import org.apache.fineract.client.models.WorkingCapitalLoanNearBreachChangeData; +import org.apache.fineract.client.models.WorkingCapitalNearBreachRequest; +import org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalLoanRequestBuilders; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanbreach.WorkingCapitalBreachHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloannearbreach.WorkingCapitalNearBreachHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class WorkingCapitalLoanNearBreachConfigTest { + + private final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + private final WorkingCapitalNearBreachHelper nearBreachHelper = new WorkingCapitalNearBreachHelper(); + private final WorkingCapitalBreachHelper breachHelper = new WorkingCapitalBreachHelper(); + + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + private final List createdNearBreachIds = new ArrayList<>(); + private final List createdBreachIds = new ArrayList<>(); + private final Long createdClientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + @AfterEach + void cleanup() { + for (final Long loanId : createdLoanIds) { + if (loanId == null) { + continue; + } + try { + loanHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseRequest()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + try { + loanHelper.undoApprovalById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildUndoApproveRequest()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + try { + loanHelper.deleteById(loanId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + } + createdLoanIds.clear(); + for (final Long productId : createdProductIds) { + if (productId == null) { + continue; + } + try { + productHelper.deleteWorkingCapitalLoanProductById(productId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + } + createdProductIds.clear(); + for (final Long nearBreachId : createdNearBreachIds) { + if (nearBreachId == null) { + continue; + } + try { + nearBreachHelper.delete(nearBreachId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + } + createdNearBreachIds.clear(); + for (final Long breachId : createdBreachIds) { + if (breachId == null) { + continue; + } + try { + breachHelper.delete(breachId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + } + createdBreachIds.clear(); + } + + @Test + public void testUpdateNearBreachConfigByIdSucceeds() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + final BigDecimal newThreshold = BigDecimal.valueOf(40); + final Integer newFrequency = 15; + final String newFrequencyType = "DAYS"; + + loanHelper.updateNearBreachConfigById(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(newThreshold, newFrequency, newFrequencyType)); + + final List history = loanHelper.getNearBreachChangeHistoryById(loanId); + assertNotNull(history); + assertFalse(history.isEmpty()); + final WorkingCapitalLoanNearBreachChangeData latest = history.getFirst(); + assertNotNull(latest.getId()); + assertEquals(0, newThreshold.compareTo(latest.getNewThreshold())); + assertEquals(newFrequency, latest.getNewFrequency()); + assertEquals(newFrequencyType, latest.getNewFrequencyType()); + assertNotEquals(Boolean.TRUE, latest.getReversed()); + } + + @Test + public void testUpdateNearBreachConfigByExternalIdSucceeds() { + final String externalId = "wcl-nb-ext-" + UUID.randomUUID().toString().substring(0, 8); + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId, externalId); + final BigDecimal newThreshold = BigDecimal.valueOf(55); + final Integer newFrequency = 7; + final String newFrequencyType = "DAYS"; + + loanHelper.updateNearBreachConfigByExternalId(externalId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(newThreshold, newFrequency, newFrequencyType)); + + final List history = loanHelper.getNearBreachChangeHistoryById(loanId); + assertNotNull(history); + assertFalse(history.isEmpty()); + final WorkingCapitalLoanNearBreachChangeData latest = history.getFirst(); + assertEquals(0, newThreshold.compareTo(latest.getNewThreshold())); + assertEquals(newFrequency, latest.getNewFrequency()); + assertEquals(newFrequencyType, latest.getNewFrequencyType()); + assertNotEquals(Boolean.TRUE, latest.getReversed()); + } + + @Test + public void testUpdateNearBreachConfigWithNoteSucceeds() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + final String note = "Adjusting near breach config for Q3"; + + CommandProcessingResult commandProcessingResult = loanHelper.updateNearBreachConfigById(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(30), 10, "DAYS", note)); + + assertNotNull(commandProcessingResult.getChanges()); + assertEquals(note, commandProcessingResult.getChanges().get("note")); + } + + @Test + public void testNearBreachChangeHistoryContainsPreviousValues() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 30, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + + loanHelper.updateNearBreachConfigById(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(50), 14, "DAYS")); + + final List history = loanHelper.getNearBreachChangeHistoryById(loanId); + assertFalse(history.isEmpty()); + final WorkingCapitalLoanNearBreachChangeData change = history.getLast(); + assertEquals(0, BigDecimal.valueOf(20).compareTo(change.getPreviousThreshold())); + assertEquals(30, change.getPreviousFrequency()); + assertEquals("DAYS", change.getPreviousFrequencyType()); + assertEquals(0, BigDecimal.valueOf(50).compareTo(change.getNewThreshold())); + assertEquals(14, change.getNewFrequency()); + } + + @Test + public void testMultipleUpdatesAppendToHistory() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + + loanHelper.updateNearBreachConfigById(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(30), 7, "DAYS")); + loanHelper.updateNearBreachConfigById(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(45), 14, "DAYS")); + loanHelper.updateNearBreachConfigById(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(60), 21, "DAYS")); + + final List history = loanHelper.getNearBreachChangeHistoryById(loanId); + assertThat(history.size()).isGreaterThanOrEqualTo(3); + assertEquals(Boolean.FALSE, history.getFirst().getReversed()); + assertEquals(Boolean.TRUE, history.get(1).getReversed()); + assertEquals(Boolean.TRUE, history.get(2).getReversed()); + } + + @Test + public void testGetHistoryReturnsEmptyListWhenNoChangesMade() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + + final List history = loanHelper.getNearBreachChangeHistoryById(loanId); + assertNotNull(history); + assertThat(history).isEmpty(); + } + + @Test + public void testNearBreachChangeRecordHasEffectiveDate() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + + loanHelper.updateNearBreachConfigById(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(30), 7, "DAYS")); + + final List history = loanHelper.getNearBreachChangeHistoryById(loanId); + assertFalse(history.isEmpty()); + final WorkingCapitalLoanNearBreachChangeData record = history.getFirst(); + assertNotNull(record.getEffectiveDate()); + } + + @Test + public void testCobEvaluatesNearBreachWithUpdatedConfig() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(50), 7, "DAYS"); + final Long[] loanIdHolder = new Long[1]; + BusinessDateHelper.runAt("01 January 2026", () -> { + loanIdHolder[0] = createActiveLoanWithNearBreach(nearBreachId, null, LocalDate.of(2026, 1, 1)); + loanHelper.updateNearBreachConfigById(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(1), 14, "DAYS")); + }); + final Long loanId = loanIdHolder[0]; + + BusinessDateHelper.runAt("09 January 2026", () -> { + FeignCalls.ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + final List schedule = loanHelper.getBreachSchedule(loanId); + assertFalse(schedule.isEmpty()); + final WorkingCapitalLoanBreachScheduleData period = schedule.getFirst(); + assertTrue(period.getNearBreach() == null || !Boolean.TRUE.equals(period.getNearBreach()), + "Near breach should not be flagged on day 7 after config changed to 14-day frequency"); + }); + + BusinessDateHelper.runAt("16 January 2026", () -> { + FeignCalls.ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanId))); + + final List schedule = loanHelper.getBreachSchedule(loanId); + assertFalse(schedule.isEmpty()); + final WorkingCapitalLoanBreachScheduleData period = schedule.getFirst(); + assertEquals(Boolean.TRUE, period.getNearBreach(), + "Near breach should be flagged on the 14-day evaluation point with no payment"); + }); + } + + @Test + public void testUpdateNearBreachConfigWithMissingThresholdFails() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + final PutWorkingCapitalLoansLoanIdNearBreachConfigRequest request = new PutWorkingCapitalLoansLoanIdNearBreachConfigRequest() + .nearBreachFrequency(7).nearBreachFrequencyType(NearBreachFrequencyTypeEnum.DAYS).locale("en"); + + final CallFailedRuntimeException ex = loanHelper.runUpdateNearBreachConfigByIdExpectingFailure(loanId, request); + assertEquals(400, ex.getStatus()); + } + + @Test + public void testUpdateNearBreachConfigWithThresholdOver100Fails() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + + final CallFailedRuntimeException ex = loanHelper.runUpdateNearBreachConfigByIdExpectingFailure(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(101), 7, "DAYS")); + assertEquals(400, ex.getStatus()); + assertThat(ex.getDeveloperMessage()).contains("must.not.exceed.100.percent"); + } + + @Test + public void testUpdateNearBreachConfigWithMissingFrequencyFails() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + final PutWorkingCapitalLoansLoanIdNearBreachConfigRequest request = new PutWorkingCapitalLoansLoanIdNearBreachConfigRequest() + .nearBreachThreshold(BigDecimal.valueOf(40)).nearBreachFrequencyType(NearBreachFrequencyTypeEnum.DAYS).locale("en"); + + final CallFailedRuntimeException ex = loanHelper.runUpdateNearBreachConfigByIdExpectingFailure(loanId, request); + assertEquals(400, ex.getStatus()); + } + + @Test + public void testUpdateNearBreachConfigOnLoanWithoutNearBreachConfigFails() { + final Long loanId = createActiveLoan(); + + final CallFailedRuntimeException ex = loanHelper.runUpdateNearBreachConfigByIdExpectingFailure(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(40), 7, "DAYS")); + assertEquals(400, ex.getStatus()); + assertThat(ex.getDeveloperMessage()).contains("near.breach.config.change.not.allowed.loan.has.no.near.breach.configuration"); + } + + @Test + public void testUpdateNearBreachConfigOnPendingLoanFails() { + final Long productId = createProduct(); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder().withClientId(createdClientId) + .withProductId(productId).withPrincipal(BigDecimal.valueOf(5000)) + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) + .withTotalPaymentVolume(BigDecimal.valueOf(100000)).buildSubmitRequest()); + + final CallFailedRuntimeException ex = loanHelper.runUpdateNearBreachConfigByIdExpectingFailure(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(40), 7, "DAYS")); + assertEquals(400, ex.getStatus()); + assertThat(ex.getDeveloperMessage()).contains("near.breach.config.change.not.allowed.for.non.active.loan"); + } + + @Test + public void testUpdateNearBreachConfigOnApprovedLoanFails() { + final Long productId = createProduct(); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder().withClientId(createdClientId) + .withProductId(productId).withPrincipal(BigDecimal.valueOf(5000)) + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) + .withTotalPaymentVolume(BigDecimal.valueOf(100000)).buildSubmitRequest()); + loanHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveRequest(Utils.getLocalDateOfTenant(), BigDecimal.valueOf(5000), null)); + + final CallFailedRuntimeException ex = loanHelper.runUpdateNearBreachConfigByIdExpectingFailure(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(40), 7, "DAYS")); + assertEquals(400, ex.getStatus()); + assertThat(ex.getDeveloperMessage()).contains("near.breach.config.change.not.allowed.for.non.active.loan"); + } + + @Test + public void testUpdateNearBreachConfigWithTooLongNoteFails() { + final Long nearBreachId = createNearBreachTemplate(BigDecimal.valueOf(20), 7, "DAYS"); + final Long loanId = createActiveLoanWithNearBreach(nearBreachId); + final String longNote = "x".repeat(1001); + + final CallFailedRuntimeException ex = loanHelper.runUpdateNearBreachConfigByIdExpectingFailure(loanId, + WorkingCapitalLoanRequestBuilders.updateNearBreachConfig(BigDecimal.valueOf(40), 7, "DAYS", longNote)); + assertEquals(400, ex.getStatus()); + } + + private Long createActiveLoan() { + final Long productId = createProduct(); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder().withClientId(createdClientId) + .withProductId(productId).withPrincipal(BigDecimal.valueOf(5000)) + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) + .withTotalPaymentVolume(BigDecimal.valueOf(100000)).buildSubmitRequest()); + final LocalDate today = Utils.getLocalDateOfTenant(); + loanHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveRequest(today, BigDecimal.valueOf(5000), null)); + loanHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildDisburseRequest(today, BigDecimal.valueOf(5000))); + return loanId; + } + + private Long createActiveLoanWithNearBreach(final Long nearBreachId) { + return createActiveLoanWithNearBreach(nearBreachId, null); + } + + private Long createActiveLoanWithNearBreach(final Long nearBreachId, final String externalId) { + final LocalDate today = Utils.getLocalDateOfTenant(); + return createActiveLoanWithNearBreach(nearBreachId, externalId, today); + } + + private Long createActiveLoanWithNearBreach(final Long nearBreachId, final String externalId, + final LocalDate approvalAndDisbursementDate) { + final Long productId = createProductWithNearBreach(nearBreachId); + final WorkingCapitalLoanApplicationTestBuilder builder = new WorkingCapitalLoanApplicationTestBuilder() + .withClientId(createdClientId).withProductId(productId).withPrincipal(BigDecimal.valueOf(5000)) + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) + .withTotalPaymentVolume(BigDecimal.valueOf(100000)); + if (externalId != null) { + builder.withExternalId(externalId); + } + final Long loanId = submitAndTrack(builder.buildSubmitRequest()); + loanHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveRequest(approvalAndDisbursementDate, BigDecimal.valueOf(5000), null)); + loanHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseRequest(approvalAndDisbursementDate, BigDecimal.valueOf(5000))); + return loanId; + } + + private Long createProduct() { + final String uniqueName = "WCL NB Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createProductWithNearBreach(final Long nearBreachId) { + final String uniqueName = "WCL NB Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + final Long breachId = breachHelper + .create(new WorkingCapitalBreachRequest().name(Utils.randomStringGenerator("Breach", 12)).breachFrequency(60) + .breachFrequencyType("DAYS").breachAmountCalculationType("PERCENTAGE").breachAmount(BigDecimal.valueOf(10))); + createdBreachIds.add(breachId); + final Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withBreachId(breachId).withNearBreachId(nearBreachId).build()).getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createNearBreachTemplate(final BigDecimal threshold, final Integer frequency, final String frequencyType) { + final String name = Utils.randomStringGenerator("NearBreach", 12); + final Long id = nearBreachHelper.create(new WorkingCapitalNearBreachRequest().nearBreachName(name).nearBreachThreshold(threshold) + .nearBreachFrequency(frequency).nearBreachFrequencyType(frequencyType)).getResourceId(); + createdNearBreachIds.add(id); + return id; + } + + private Long submitAndTrack(final PostWorkingCapitalLoansRequest request) { + final Long loanId = loanHelper.submit(request); + createdLoanIds.add(loanId); + return loanId; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java index d448419ad66..25c9af08da0 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java @@ -22,6 +22,8 @@ import org.apache.fineract.client.models.PostWorkingCapitalLoanTransactionsRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdNearBreachConfigRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdNearBreachConfigRequest.NearBreachFrequencyTypeEnum; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRateRequest; public final class WorkingCapitalLoanRequestBuilders { @@ -73,6 +75,17 @@ public static PutWorkingCapitalLoansLoanIdRateRequest updateRate(BigDecimal newR return new PutWorkingCapitalLoansLoanIdRateRequest().periodPaymentRate(newRate).locale(LOCALE); } + public static PutWorkingCapitalLoansLoanIdNearBreachConfigRequest updateNearBreachConfig(BigDecimal threshold, Integer frequency, + String frequencyType) { + return new PutWorkingCapitalLoansLoanIdNearBreachConfigRequest().nearBreachThreshold(threshold).nearBreachFrequency(frequency) + .nearBreachFrequencyType(NearBreachFrequencyTypeEnum.fromValue(frequencyType)).locale(LOCALE); + } + + public static PutWorkingCapitalLoansLoanIdNearBreachConfigRequest updateNearBreachConfig(BigDecimal threshold, Integer frequency, + String frequencyType, String note) { + return updateNearBreachConfig(threshold, frequency, frequencyType).note(note); + } + public static PostWorkingCapitalLoanTransactionsRequest repayment(BigDecimal amount, String transactionDate) { return new PostWorkingCapitalLoanTransactionsRequest().transactionAmount(amount).transactionDate(transactionDate).locale(LOCALE) .dateFormat(DATE_FORMAT); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java index d35ffb4a907..cdf06182cbf 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanHelper.java @@ -20,11 +20,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.util.List; import java.util.Map; +import org.apache.fineract.client.feign.services.WorkingCapitalLoanBreachScheduleApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanTransactionsApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoansApi; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.feign.util.FeignCalls; +import org.apache.fineract.client.models.CommandProcessingResult; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionsResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; @@ -35,7 +38,10 @@ import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse; import org.apache.fineract.client.models.ProjectedAmortizationScheduleData; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdNearBreachConfigRequest; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest; +import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData; +import org.apache.fineract.client.models.WorkingCapitalLoanNearBreachChangeData; import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; /** @@ -53,6 +59,10 @@ private static WorkingCapitalLoanTransactionsApi transactionsApi() { return FineractFeignClientHelper.getFineractFeignClient().create(WorkingCapitalLoanTransactionsApi.class); } + private static WorkingCapitalLoanBreachScheduleApi breachScheduleApi() { + return FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoanBreachSchedule(); + } + public Long submit(final PostWorkingCapitalLoansRequest request) { PostWorkingCapitalLoansResponse response = FeignCalls.ok(() -> api().submitWorkingCapitalLoanApplication(request)); return response.getResourceId(); @@ -227,6 +237,33 @@ public CallFailedRuntimeException runModifyExpectingFailure(final Long loanId, f return FeignCalls.fail(() -> api().modifyWorkingCapitalLoanApplicationById(loanId, request, Map.of())); } + public CommandProcessingResult updateNearBreachConfigById(final Long loanId, + final PutWorkingCapitalLoansLoanIdNearBreachConfigRequest request) { + return FeignCalls.ok(() -> api().updateWorkingCapitalLoanNearBreachConfigById(loanId, request)); + } + + public void updateNearBreachConfigByExternalId(final String externalId, + final PutWorkingCapitalLoansLoanIdNearBreachConfigRequest request) { + FeignCalls.ok(() -> api().updateWorkingCapitalLoanNearBreachConfigByExternalId(externalId, request)); + } + + public CallFailedRuntimeException runUpdateNearBreachConfigByIdExpectingFailure(final Long loanId, + final PutWorkingCapitalLoansLoanIdNearBreachConfigRequest request) { + return FeignCalls.fail(() -> api().updateWorkingCapitalLoanNearBreachConfigById(loanId, request)); + } + + public List getBreachSchedule(final Long loanId) { + return FeignCalls.ok(() -> breachScheduleApi().retrieveBreachSchedule(loanId)); + } + + public List getNearBreachChangeHistoryById(final Long loanId) { + return FeignCalls.ok(() -> api().getWorkingCapitalLoanNearBreachChangeHistoryById(loanId)); + } + + public List getNearBreachChangeHistoryByExternalId(final String externalId) { + return FeignCalls.ok(() -> api().getWorkingCapitalLoanNearBreachChangeHistoryByExternalId(externalId)); + } + public GetWorkingCapitalLoansLoanIdResponse retrieveLoan(final Long loanId) { final GetWorkingCapitalLoansLoanIdResponse response = retrieveById(loanId); assertNotNull(response);