diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java index 10848c09f73..06b1fad4cfe 100644 --- a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java +++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java @@ -155,6 +155,7 @@ import org.apache.fineract.client.feign.services.TwoFactorApi; import org.apache.fineract.client.feign.services.UsersApi; import org.apache.fineract.client.feign.services.WorkingCapitalBreachApi; +import org.apache.fineract.client.feign.services.WorkingCapitalLoanAccountLockApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanBreachScheduleApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanCobCatchUpApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanDelinquencyActionsApi; @@ -756,6 +757,10 @@ public WorkingCapitalLoanProductsApi workingCapitalLoanProducts() { return create(WorkingCapitalLoanProductsApi.class); } + public WorkingCapitalLoanAccountLockApi workingCapitalLoanAccountLock() { + return create(WorkingCapitalLoanAccountLockApi.class); + } + public WorkingCapitalLoanCobCatchUpApi workingCapitalLoanCobCatchUpApi() { return create(WorkingCapitalLoanCobCatchUpApi.class); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/api/LockRequest.java b/fineract-cob/src/main/java/org/apache/fineract/cob/api/LockRequest.java similarity index 89% rename from fineract-provider/src/main/java/org/apache/fineract/cob/api/LockRequest.java rename to fineract-cob/src/main/java/org/apache/fineract/cob/api/LockRequest.java index 0b33c0acb64..d28e114daa3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/api/LockRequest.java +++ b/fineract-cob/src/main/java/org/apache/fineract/cob/api/LockRequest.java @@ -18,10 +18,13 @@ */ package org.apache.fineract.cob.api; +import java.time.LocalDate; import lombok.Data; @Data public class LockRequest { private String error; + private LocalDate cobBusinessDate; + private Boolean nullCobBusinessDate; } diff --git a/fineract-cob/src/main/java/org/apache/fineract/cob/domain/AccountLockRepository.java b/fineract-cob/src/main/java/org/apache/fineract/cob/domain/AccountLockRepository.java index 9d1a34dd7b4..ed7b6406647 100644 --- a/fineract-cob/src/main/java/org/apache/fineract/cob/domain/AccountLockRepository.java +++ b/fineract-cob/src/main/java/org/apache/fineract/cob/domain/AccountLockRepository.java @@ -41,6 +41,8 @@ public interface AccountLockRepository { void removeByLockOwnerInAndErrorIsNotNullAndLockPlacedOnCobBusinessDateIsNotNull(List lockOwners); + int deleteOrphanedLocksForProcessedAccounts(List lockOwners); + Page findAll(Pageable loanAccountLockPage); T saveAndFlush(T entity); diff --git a/fineract-cob/src/main/java/org/apache/fineract/cob/service/AbstractAccountLockService.java b/fineract-cob/src/main/java/org/apache/fineract/cob/service/AbstractAccountLockService.java index 29d5aa8a531..754ccb8c02f 100644 --- a/fineract-cob/src/main/java/org/apache/fineract/cob/service/AbstractAccountLockService.java +++ b/fineract-cob/src/main/java/org/apache/fineract/cob/service/AbstractAccountLockService.java @@ -33,6 +33,9 @@ @RequiredArgsConstructor public abstract class AbstractAccountLockService implements AccountLockService { + protected static final List COB_LOCK_OWNERS = List.of(LockOwner.LOAN_COB_CHUNK_PROCESSING, + LockOwner.LOAN_INLINE_COB_PROCESSING); + private final AccountLockRepository loanAccountLockRepository; private final CustomLoanAccountLockRepository customLoanAccountLockRepository; @@ -59,8 +62,13 @@ public boolean isLockOverrulable(Long loanId) { @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateCobAndRemoveLocks() { customLoanAccountLockRepository.updateLoanFromAccountLocks(); - loanAccountLockRepository.removeByLockOwnerInAndErrorIsNotNullAndLockPlacedOnCobBusinessDateIsNotNull( - List.of(LockOwner.LOAN_COB_CHUNK_PROCESSING, LockOwner.LOAN_INLINE_COB_PROCESSING)); + loanAccountLockRepository.removeByLockOwnerInAndErrorIsNotNullAndLockPlacedOnCobBusinessDateIsNotNull(COB_LOCK_OWNERS); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int removeOrphanedLocksForProcessedAccounts() { + return loanAccountLockRepository.deleteOrphanedLocksForProcessedAccounts(COB_LOCK_OWNERS); } } diff --git a/fineract-cob/src/main/java/org/apache/fineract/cob/service/AccountLockService.java b/fineract-cob/src/main/java/org/apache/fineract/cob/service/AccountLockService.java index 4f55670ac2f..d7e8918c760 100644 --- a/fineract-cob/src/main/java/org/apache/fineract/cob/service/AccountLockService.java +++ b/fineract-cob/src/main/java/org/apache/fineract/cob/service/AccountLockService.java @@ -30,4 +30,6 @@ public interface AccountLockService { boolean isLockOverrulable(Long loanId); void updateCobAndRemoveLocks(); + + int removeOrphanedLocksForProcessedAccounts(); } diff --git a/fineract-cob/src/main/java/org/apache/fineract/cob/tasklet/UnlockProcessedAccountsTasklet.java b/fineract-cob/src/main/java/org/apache/fineract/cob/tasklet/UnlockProcessedAccountsTasklet.java new file mode 100644 index 00000000000..b93e172003e --- /dev/null +++ b/fineract-cob/src/main/java/org/apache/fineract/cob/tasklet/UnlockProcessedAccountsTasklet.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.cob.tasklet; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.cob.domain.AccountLock; +import org.apache.fineract.cob.service.AccountLockService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.lang.NonNull; + +/** + * Tasklet that unlocks accounts which were successfully processed during COB but whose locks were not removed. + * + * An account is considered successfully processed when its {@code last_closed_business_date} matches the + * {@code lock_placed_on_cob_business_date} on the lock record, proving all COB business steps completed. If the lock + * still exists with no error, it is orphaned and should be removed. + */ +@Slf4j +@RequiredArgsConstructor +public abstract class UnlockProcessedAccountsTasklet implements Tasklet { + + private final AccountLockService accountLockService; + + @Override + public RepeatStatus execute(@NonNull final StepContribution contribution, @NonNull final ChunkContext chunkContext) throws Exception { + final int removedCount = accountLockService.removeOrphanedLocksForProcessedAccounts(); + if (removedCount > 0) { + log.info("Unlocked {} account(s) that completed COB processing but remained locked", removedCount); + } else { + log.debug("No orphaned account locks found after COB processing"); + } + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-doc/src/docs/en/chapters/architecture/loan-locking.adoc b/fineract-doc/src/docs/en/chapters/architecture/loan-locking.adoc index a82006cdd96..d825db7271d 100644 --- a/fineract-doc/src/docs/en/chapters/architecture/loan-locking.adoc +++ b/fineract-doc/src/docs/en/chapters/architecture/loan-locking.adoc @@ -21,4 +21,16 @@ And when a loan account is locked, loan related write API calls will be either r Since the fixing might involve making changes to the loan account via API (for example doing a repayment to fix the loan account's inconsistent state), we need to allow those API calls. Hence, the lock table includes a `bypass_enabled` column which disables the lock checks on the loan write APIs. +== Orphaned COB lock cleanup + +A separate failure mode exists where a loan account is fully processed by COB but its lock row stays in `m_loan_account_locks` (no `error` was recorded, yet the lock was never removed). Such locks are orphaned: the loan is in a consistent state, but real-time write APIs are still rejected. + +To remove these, the Loan COB job runs a final step `unlockProcessedLoansStep` (after `stayedLockedStep`) which executes `UnlockProcessedLoansTasklet`. The tasklet delegates to `AccountLockService#removeOrphanedLocksForProcessedAccounts`, which deletes lock rows that meet all of the following: + +* `lock_owner` is `LOAN_COB_CHUNK_PROCESSING` or `LOAN_INLINE_COB_PROCESSING` +* `error IS NULL` +* `lock_placed_on_cob_business_date IS NOT NULL` +* the referenced loan's `last_closed_business_date` equals the lock's `lock_placed_on_cob_business_date` (i.e. all business steps for that COB date completed) + +The same mechanism is wired into the Working Capital Loan COB job via `UnlockProcessedWorkingCapitalLoansTasklet`. Locks that carry an `error` are intentionally left in place – they still represent a loan that needs manual fixing and `bypass_enabled` handling continues to apply. diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index 4650eb8088d..a0c0485e2bb 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -681,6 +681,11 @@ public static String listOfLockedLoansContainsLoan(Long loanId, LoanAccountLockR return String.format("List of locked loan accounts contains the loan with loanId %s. List of locked loans: %n%s", loanId, bodyStr); } + public static String expectedLoanToRemainLocked(Long loanId, LoanAccountLockResponseDTO response) { + String bodyStr = response.toString(); + return String.format("Expected loan %s to remain locked after COB but it is not present in the lock list: %n%s", loanId, bodyStr); + } + public static String wrongValueInLineDelinquencyActions(int line, List actual, List expected) { String lineStr = String.valueOf(line); String expectedStr = expected.toString(); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/WorkingCapitalLoanCobStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/WorkingCapitalLoanCobStepDef.java index 24a2317e080..bf170854973 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/WorkingCapitalLoanCobStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/WorkingCapitalLoanCobStepDef.java @@ -28,19 +28,20 @@ import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; -import java.io.IOException; import java.time.Duration; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Map; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.models.BusinessDateResponse; import org.apache.fineract.client.models.InlineJobRequest; import org.apache.fineract.client.models.IsCatchUpRunningDTO; +import org.apache.fineract.client.models.LockRequest; import org.apache.fineract.client.models.OldestCOBProcessedLoanDTO; import org.apache.fineract.client.models.PostClientsResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsResponse; @@ -51,17 +52,15 @@ import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; import org.junit.jupiter.api.Assertions; -import org.springframework.beans.factory.annotation.Autowired; @Slf4j +@RequiredArgsConstructor public class WorkingCapitalLoanCobStepDef extends AbstractStepDef { private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd MMMM yyyy"); - @Autowired - private WorkingCapitalLoanTestHelper wcLoanHelper; - @Autowired - private FineractFeignClient fineractClient; + private final WorkingCapitalLoanTestHelper wcLoanHelper; + private final FineractFeignClient fineractClient; @Before(value = "@WCCOBFeature") public void beforeWcCobScenario() { @@ -87,16 +86,16 @@ public void afterWcCobScenario() { } @When("Admin runs inline COB job for Working Capital Loan") - public void runWorkingCapitalInlineCOB() throws IOException { + public void runWorkingCapitalInlineCOB() { InlineJobRequest inlineJobRequest = new InlineJobRequest().addLoanIdsItem(getTrackedLoanIds().getLast()); ok(() -> fineractClient.inlineJob().executeInlineJob("WC_LOAN_COB", inlineJobRequest)); } @When("Admin runs inline COB job for Working Capital Loan by loanId") - public void runWorkingCapitalInlineCOBByLoanId() throws IOException { + public void runWorkingCapitalInlineCOBByLoanId() { PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); Assertions.assertNotNull(loanResponse); - long loanId = loanResponse.getLoanId(); + Long loanId = loanResponse.getLoanId(); InlineJobRequest inlineJobRequest = new InlineJobRequest().addLoanIdsItem(loanId); @@ -104,7 +103,7 @@ public void runWorkingCapitalInlineCOBByLoanId() throws IOException { } @When("Admin runs inline COB job for all Working Capital Loans") - public void runWorkingCapitalInlineCOBForAll() throws IOException { + public void runWorkingCapitalInlineCOBForAll() { InlineJobRequest inlineJobRequest = new InlineJobRequest(); for (Long loanId : getTrackedLoanIds()) { inlineJobRequest.addLoanIdsItem(loanId); @@ -200,6 +199,110 @@ public void verifyAllLoansHaveNoAccountLocks() { } } + @Then("Admin verifies all inserted WC loans have at least one account lock") + public void verifyAllLoansHaveAtLeastOneAccountLock() { + final List loanIds = getTrackedLoanIds(); + assertThat(loanIds).as("No WC loan IDs tracked in test context").isNotEmpty(); + for (final Long loanId : loanIds) { + final int lockCount = wcLoanHelper.countLocksByLoanId(loanId); + log.debug("WC loan id={} lock count={}", loanId, lockCount); + assertThat(lockCount)// + .as("WC loan id=%d — expected at least one account lock but got %d", loanId, lockCount)// + .isPositive(); + } + } + + @When("Admin places a chunk-processing lock without an error message on the last inserted WC loan") + public void placeChunkLockWithoutErrorOnLastWcLoan() { + final Long loanId = getTrackedLoanIds().getLast(); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_COB_CHUNK_PROCESSING", new LockRequest())); + log.debug("Placed chunk-processing lock without error on WC loan id={}", loanId); + } + + @When("Admin places a chunk-processing lock with error {string} on the last inserted WC loan") + public void placeChunkLockWithErrorOnLastWcLoan(final String error) { + final Long loanId = getTrackedLoanIds().getLast(); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_COB_CHUNK_PROCESSING", new LockRequest().error(error))); + log.debug("Placed chunk-processing lock with error '{}' on WC loan id={}", error, loanId); + } + + @When("Admin places an inline-COB lock without an error message on the last inserted WC loan") + public void placeInlineLockWithoutErrorOnLastWcLoan() { + final Long loanId = getTrackedLoanIds().getLast(); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_INLINE_COB_PROCESSING", new LockRequest())); + log.debug("Placed inline-COB lock without error on WC loan id={}", loanId); + } + + @When("Admin places an inline-COB lock with error {string} on the last inserted WC loan") + public void placeInlineLockWithErrorOnLastWcLoan(final String error) { + final Long loanId = getTrackedLoanIds().getLast(); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_INLINE_COB_PROCESSING", new LockRequest().error(error))); + log.debug("Placed inline-COB lock with error '{}' on WC loan id={}", error, loanId); + } + + @When("Admin places a chunk-processing lock without an error message and null cob business date on the last inserted WC loan") + public void placeChunkLockWithNullCobDateOnLastWcLoan() { + final Long loanId = getTrackedLoanIds().getLast(); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_COB_CHUNK_PROCESSING", new LockRequest().nullCobBusinessDate(true))); + log.debug("Placed chunk-processing lock with null cob date on WC loan id={}", loanId); + } + + @When("Admin places a chunk-processing lock without an error message and cob business date {string} on the last inserted WC loan") + public void placeChunkLockWithExplicitCobDateOnLastWcLoan(final String cobBusinessDate) { + final Long loanId = getTrackedLoanIds().getLast(); + final LocalDate parsed = LocalDate.parse(cobBusinessDate, DATE_FORMAT); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_COB_CHUNK_PROCESSING", new LockRequest().cobBusinessDate(parsed))); + log.debug("Placed chunk-processing lock with explicit cob date {} on WC loan id={}", parsed, loanId); + } + + @When("Admin places a chunk-processing lock without an error message on WC loan {int}") + public void placeChunkLockWithoutErrorOnWcLoanAtIndex(final int index) { + final Long loanId = loanAtIndex(index); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_COB_CHUNK_PROCESSING", new LockRequest())); + log.debug("Placed chunk-processing lock without error on WC loan index={} id={}", index, loanId); + } + + @When("Admin places a chunk-processing lock with error {string} on WC loan {int}") + public void placeChunkLockWithErrorOnWcLoanAtIndex(final String error, final int index) { + final Long loanId = loanAtIndex(index); + executeVoid(() -> fineractClient.workingCapitalLoanAccountLock().placeLockOnWorkingCapitalLoanAccount(loanId, + "LOAN_COB_CHUNK_PROCESSING", new LockRequest().error(error))); + log.debug("Placed chunk-processing lock with error '{}' on WC loan index={} id={}", error, index, loanId); + } + + @Then("Admin verifies inserted WC loan {int} has no account locks") + public void verifyLoanAtIndexHasNoLocks(final int index) { + final Long loanId = loanAtIndex(index); + final int lockCount = wcLoanHelper.countLocksByLoanId(loanId); + log.debug("WC loan index={} id={} lock count={}", index, loanId, lockCount); + assertThat(lockCount)// + .as("WC loan index=%d id=%d — expected 0 account locks but got %d", index, loanId, lockCount)// + .isZero(); + } + + @Then("Admin verifies inserted WC loan {int} has at least one account lock") + public void verifyLoanAtIndexHasAtLeastOneLock(final int index) { + final Long loanId = loanAtIndex(index); + final int lockCount = wcLoanHelper.countLocksByLoanId(loanId); + log.debug("WC loan index={} id={} lock count={}", index, loanId, lockCount); + assertThat(lockCount)// + .as("WC loan index=%d id=%d — expected at least one account lock but got %d", index, loanId, lockCount)// + .isPositive(); + } + + private Long loanAtIndex(final int index) { + final List loanIds = getTrackedLoanIds(); + assertThat(index).as("Loan index %d out of range (1..%d)", index, loanIds.size()).isBetween(1, loanIds.size()); + return loanIds.get(index - 1); + } + @Then("Admin verifies inserted WC loan {int} has lastClosedBusinessDate {string}") public void verifyLoanAtIndexHasLastClosedBusinessDate(int index, String expectedDate) { LocalDate expected = LocalDate.parse(expectedDate, DATE_FORMAT); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanCOBStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanCOBStepDef.java index fb84780c93f..fe1b32261ef 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanCOBStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanCOBStepDef.java @@ -24,10 +24,11 @@ import io.cucumber.java.en.Then; import io.cucumber.java.en.When; -import java.io.IOException; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.models.LoanAccountLock; @@ -38,19 +39,20 @@ import org.apache.fineract.test.helper.ErrorMessageHelper; import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; -import org.springframework.beans.factory.annotation.Autowired; +import org.junit.jupiter.api.Assertions; @Slf4j +@RequiredArgsConstructor public class LoanCOBStepDef extends AbstractStepDef { - @Autowired - private FineractFeignClient fineractClient; + private final FineractFeignClient fineractClient; @Then("The cobProcessedDate of the oldest loan processed by COB is more than 1 day earlier than cobBusinessDate") - public void checkOldestCOBProcessed() throws IOException { + public void checkOldestCOBProcessed() { OldestCOBProcessedLoanDTO response = ok(() -> fineractClient.loanCobCatchUp().getOldestCOBProcessedLoan()); LocalDate cobDate = response.getCobBusinessDate(); + Assertions.assertNotNull(cobDate); LocalDate cobDateMinusOne = cobDate.minusDays(1); LocalDate cobProcessedDate = response.getCobProcessedDate(); log.debug("cobDateMinusOne: {}", cobDateMinusOne); @@ -61,23 +63,26 @@ public void checkOldestCOBProcessed() throws IOException { } @Then("There are no locked loan accounts") - public void listOfLockedLoansEmpty() throws IOException { + public void listOfLockedLoansEmpty() { LoanAccountLockResponseDTO response = ok( - () -> fineractClient.loanAccountLock().retrieveLockedAccounts(Map.of("page", 0, "size", 1000))); + () -> fineractClient.loanAccountLock().retrieveLockedAccounts(Map.of("page", 0, "size", 1000))); + Assertions.assertNotNull(response.getContent()); int size = response.getContent().size(); assertThat(size).as(ErrorMessageHelper.listOfLockedLoansNotEmpty(response)).isEqualTo(0); log.debug("Size of List of the locked loans: {}", size); } @Then("The loan account is not locked") - public void loanIsNotInListOfLockedLoans() throws IOException { + public void loanIsNotInListOfLockedLoans() { PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); Long targetLoanId = loanResponse.getLoanId(); LoanAccountLockResponseDTO response = ok( - () -> fineractClient.loanAccountLock().retrieveLockedAccounts(Map.of("page", 0, "size", 1000))); + () -> fineractClient.loanAccountLock().retrieveLockedAccounts(Map.of("page", 0, "size", 1000))); + Assertions.assertNotNull(response.getContent()); + Assertions.assertNotNull(targetLoanId); List content = response.getContent(); boolean contains = content.stream()// .map(LoanAccountLock::getLoanId)// @@ -86,20 +91,124 @@ public void loanIsNotInListOfLockedLoans() throws IOException { assertThat(contains).as(ErrorMessageHelper.listOfLockedLoansContainsLoan(targetLoanId, response)).isFalse(); } + @Then("The loan account is locked by chunk processing") + public void loanIsLockedByChunkProcessing() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final Long targetLoanId = loanResponse.getLoanId(); + + final LoanAccountLockResponseDTO response = ok( + () -> fineractClient.loanAccountLock().retrieveLockedAccounts(Map.of("page", 0, "size", 1000))); + + Assertions.assertNotNull(response.getContent()); + Assertions.assertNotNull(targetLoanId); + final boolean stillLocked = response.getContent().stream()// + .map(LoanAccountLock::getLoanId)// + .anyMatch(targetLoanId::equals);// + + assertThat(stillLocked).as(ErrorMessageHelper.expectedLoanToRemainLocked(targetLoanId, response)).isTrue(); + } + @When("Admin places a lock on loan account with an error message") - public void placeLockOnLoanAccountWithErrorMessage() throws IOException { + public void placeLockOnLoanAccountWithErrorMessage() { PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); - long loanId = loanResponse.getLoanId(); + Long loanId = loanResponse.getLoanId(); - executeVoid(() -> fineractClient.defaultApi().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", new LockRequest().error("ERROR"))); } @When("Admin places a lock on loan account WITHOUT an error message") - public void placeLockOnLoanAccountNoErrorMessage() throws IOException { + public void placeLockOnLoanAccountNoErrorMessage() { PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); - long loanId = loanResponse.getLoanId(); + Long loanId = loanResponse.getLoanId(); + + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", new LockRequest())); + } + + @When("Admin places an inline COB lock on loan account WITHOUT an error message") + public void placeInlineLockOnLoanAccountNoErrorMessage() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final Long loanId = loanResponse.getLoanId(); + + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_INLINE_COB_PROCESSING", new LockRequest())); + } + + @When("Admin places an inline COB lock on loan account with an error message") + public void placeInlineLockOnLoanAccountWithErrorMessage() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final Long loanId = loanResponse.getLoanId(); + + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_INLINE_COB_PROCESSING", + new LockRequest().error("ERROR"))); + } + + @When("Admin places a lock on loan account WITHOUT an error message and null cob business date") + public void placeLockOnLoanAccountWithNullCobBusinessDate() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final Long loanId = loanResponse.getLoanId(); + + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", + new LockRequest().nullCobBusinessDate(true))); + } + + @When("Admin places a lock on loan account WITHOUT an error message and cob business date {string}") + public void placeLockOnLoanAccountWithExplicitCobBusinessDate(final String cobBusinessDate) { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final Long loanId = loanResponse.getLoanId(); + final LocalDate parsed = LocalDate.parse(cobBusinessDate, DateTimeFormatter.ofPattern("dd MMMM yyyy")); + + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", + new LockRequest().cobBusinessDate(parsed))); + } + + @When("Admin places a lock on second loan account WITHOUT an error message") + public void placeLockOnSecondLoanAccountNoErrorMessage() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_SECOND_LOAN_RESPONSE); + final Long loanId = loanResponse.getLoanId(); + + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", new LockRequest())); + } + + @When("Admin places a lock on second loan account with an error message") + public void placeLockOnSecondLoanAccountWithErrorMessage() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_SECOND_LOAN_RESPONSE); + final Long loanId = loanResponse.getLoanId(); + + executeVoid(() -> fineractClient.loanAccountLock().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", + new LockRequest().error("ERROR"))); + } + + @Then("The second loan account is not locked") + public void secondLoanIsNotInListOfLockedLoans() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_SECOND_LOAN_RESPONSE); + final Long targetLoanId = loanResponse.getLoanId(); + + final LoanAccountLockResponseDTO response = ok( + () -> fineractClient.loanAccountLock().retrieveLockedAccounts(Map.of("page", 0, "size", 1000))); + + Assertions.assertNotNull(response.getContent()); + Assertions.assertNotNull(targetLoanId); + final boolean contains = response.getContent().stream()// + .map(LoanAccountLock::getLoanId)// + .anyMatch(targetLoanId::equals); + + assertThat(contains).as(ErrorMessageHelper.listOfLockedLoansContainsLoan(targetLoanId, response)).isFalse(); + } + + @Then("The second loan account is locked by chunk processing") + public void secondLoanIsLockedByChunkProcessing() { + final PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_SECOND_LOAN_RESPONSE); + final Long targetLoanId = loanResponse.getLoanId(); + + final LoanAccountLockResponseDTO response = ok( + () -> fineractClient.loanAccountLock().retrieveLockedAccounts(Map.of("page", 0, "size", 1000))); + + Assertions.assertNotNull(response.getContent()); + Assertions.assertNotNull(targetLoanId); + final boolean stillLocked = response.getContent().stream()// + .map(LoanAccountLock::getLoanId)// + .anyMatch(targetLoanId::equals); - executeVoid(() -> fineractClient.defaultApi().placeLockOnLoanAccount(loanId, "LOAN_COB_CHUNK_PROCESSING", new LockRequest())); + assertThat(stillLocked).as(ErrorMessageHelper.expectedLoanToRemainLocked(targetLoanId, response)).isTrue(); } } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/0_COB.feature b/fineract-e2e-tests-runner/src/test/resources/features/0_COB.feature index 4ac7ad28f63..7e651ff2ec7 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/0_COB.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/0_COB.feature @@ -225,4 +225,126 @@ Feature: COBFeature When Admin runs inline COB job for Loan Then LoanAccountCustomSnapshotBusinessEvent is created with business date "17 January 2022" + Scenario: COB removes an orphaned lock with no error on a loan already processed for that COB date + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + When Admin places a lock on loan account WITHOUT an error message + When Admin runs COB job + Then The loan account is not locked + And Customer makes "AUTOPAY" repayment on "02 January 2022" with 1000 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + + Scenario: COB keeps a lock that carries an error message + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + When Admin places a lock on loan account with an error message + When Admin runs COB job + Then The loan account is locked by chunk processing + + Scenario: COB keeps a lock when the loan's last closed business date does not match the lock's COB date + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + # Skip a few days so the lock's cob_date will be far ahead of the loan's last_closed_business_date. + # Even if the next COB advances last_closed by one day, it still won't reach the lock's cob_date. + When Admin sets the business date to "05 January 2022" + When Admin places a lock on loan account WITHOUT an error message + When Admin runs COB job + Then The loan account is locked by chunk processing + + Scenario: COB removes an orphaned inline-COB lock with no error on a loan already processed for that COB date + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + When Admin places an inline COB lock on loan account WITHOUT an error message + When Admin runs COB job + Then The loan account is not locked + Scenario: COB keeps an inline-COB lock that carries an error message + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + When Admin places an inline COB lock on loan account with an error message + When Admin runs COB job + Then The loan account is locked by chunk processing + + Scenario: COB keeps a lock with NULL cob business date + # SQL filter requires `lock_placed_on_cob_business_date IS NOT NULL` — a lock with NULL date must never be removed + # (no proof exists that the loan was already processed for that date). + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + When Admin places a lock on loan account WITHOUT an error message and null cob business date + When Admin runs COB job + Then The loan account is locked by chunk processing + + Scenario: COB keeps a lock when cob business date is in the past relative to last closed date + # SQL uses `last_closed_business_date = lock_placed_on_cob_business_date` (strict equality), so a stale lock + # whose cob date already lags behind the loan's last_closed must remain — the date proves a *different* run. + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + When Admin sets the business date to "03 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "02 January 2022" + # Loan's last_closed is now 02 Jan; place a lock that claims cob_date = 01 Jan (earlier) — must NOT be removed. + When Admin places a lock on loan account WITHOUT an error message and cob business date "01 January 2022" + When Admin runs COB job + Then The loan account is locked by chunk processing + + Scenario: COB removes only orphaned locks among multiple loans in the same run + # Two loans share the same COB run: the first one carries an orphaned lock (no error) and must be unlocked; + # the second one carries a lock with an error message and must remain locked. Verifies DELETE selectivity. + When Admin sets the business date to "01 January 2022" + When Admin creates a client with random data + When Admin creates a new default Loan with date: "01 January 2022" + And Admin successfully approves the loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + When Admin successfully disburse the loan on "01 January 2022" with "1000" EUR transaction amount + When Admin crates a second default loan with date: "01 January 2022" + And Admin successfully approves the second loan on "01 January 2022" with "1000" amount and expected disbursement date on "01 January 2022" + And Admin successfully disburse the second loan on "01 January 2022" with "1000" EUR transaction amount + When Admin sets the business date to "02 January 2022" + When Admin runs COB job + Then Admin checks that last closed business date of loan is "01 January 2022" + When Admin places a lock on loan account WITHOUT an error message + When Admin places a lock on second loan account with an error message + When Admin runs COB job + Then The loan account is not locked + Then The second loan account is locked by chunk processing diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature index 11700c7c5eb..5ae518112aa 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapital_COB.feature @@ -258,4 +258,103 @@ Feature: Working Capital COB Job Then Admin verifies all inserted WC loans have version 1 When Admin runs Working Capital COB catch up Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" - Then Admin verifies all inserted WC loans have version 1 \ No newline at end of file + Then Admin verifies all inserted WC loans have version 1 + + Scenario: WC COB removes an orphaned lock with no error on a loan already processed for that COB date + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts an active WC loan into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" + When Admin places a chunk-processing lock without an error message on the last inserted WC loan + Then Admin verifies all inserted WC loans have at least one account lock + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have no account locks + + Scenario: WC COB keeps a lock that carries an error message + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts an active WC loan into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" + When Admin places a chunk-processing lock with error "ERROR" on the last inserted WC loan + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have at least one account lock + + Scenario: WC COB keeps a lock when the loan's last closed business date does not match the lock's COB date + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts an active WC loan into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" + # Skip a few days so the lock's cob_date is far ahead of the loan's last_closed (31 Dec 2023). + # Even if WC COB advances last_closed by one day during the next run, it won't reach the lock's cob_date. + When Admin sets the business date to "05 January 2024" + When Admin places a chunk-processing lock without an error message on the last inserted WC loan + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have at least one account lock + + Scenario: WC COB removes an orphaned inline-COB lock with no error on a processed loan + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts an active WC loan into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" + When Admin places an inline-COB lock without an error message on the last inserted WC loan + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have no account locks + + Scenario: WC COB keeps an inline-COB lock that carries an error message + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts an active WC loan into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" + When Admin places an inline-COB lock with error "ERROR" on the last inserted WC loan + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have at least one account lock + + Scenario: WC COB keeps a lock with NULL cob business date + # SQL filter requires `lock_placed_on_cob_business_date IS NOT NULL` — a lock with NULL date must never be removed. + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts an active WC loan into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" + When Admin places a chunk-processing lock without an error message and null cob business date on the last inserted WC loan + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have at least one account lock + + Scenario: WC COB keeps a lock when cob business date is in the past relative to last closed date + # SQL uses strict equality on dates — a stale lock whose cob_date already lags behind last_closed must remain. + When Admin sets the business date to "02 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts a WC loan with status "ACTIVE" and lastClosedBusinessDate "31 December 2023" into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "01 January 2024" + # Place a lock claiming cob_date = 31 Dec 2023 — earlier than the loan's current last_closed (01 Jan 2024). + When Admin places a chunk-processing lock without an error message and cob business date "31 December 2023" on the last inserted WC loan + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have at least one account lock + + Scenario: WC COB removes only orphaned locks among multiple loans in the same run + # Two loans share the same COB run: one carries an orphaned lock (no error) and must be unlocked; + # the other carries a lock with an error message and must remain locked. Verifies DELETE selectivity. + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a new Working Capital Loan Product + Given Admin inserts 2 active WC loans into the database + When Admin runs WC COB job + Then Admin verifies all inserted WC loans have lastClosedBusinessDate "31 December 2023" + When Admin places a chunk-processing lock without an error message on WC loan 1 + When Admin places a chunk-processing lock with error "ERROR" on WC loan 2 + When Admin runs WC COB job + Then Admin verifies inserted WC loan 1 has no account locks + Then Admin verifies inserted WC loan 2 has at least one account lock \ No newline at end of file diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java index c61696e1562..89781fb34ca 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java @@ -19,6 +19,7 @@ package org.apache.fineract.cob.api; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -28,6 +29,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -45,6 +47,7 @@ @Profile(FineractProfiles.TEST) @Component @Path("/v1/internal/loans") +@Tag(name = "Loan Account Lock") @RequiredArgsConstructor @Slf4j public class InternalLoanAccountLockApiResource implements InitializingBean { @@ -69,21 +72,31 @@ public void afterPropertiesSet() throws Exception { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @SuppressFBWarnings("SLF4J_SIGN_ONLY_FORMAT") - public Response placeLockOnLoanAccount(@Context final UriInfo uriInfo, @PathParam("loanId") Long loanId, - @PathParam("lockOwner") String lockOwner, @RequestBody(required = false) LockRequest request) { + public Response placeLockOnLoanAccount(@Context final UriInfo uriInfo, @PathParam("loanId") final Long loanId, + @PathParam("lockOwner") final String lockOwner, @RequestBody(required = false) final LockRequest request) { log.warn("------------------------------------------------------------"); log.warn(" "); log.warn("Placing lock on loan: {}", loanId); log.warn(" "); log.warn("------------------------------------------------------------"); - LoanAccountLock loanAccountLock = new LoanAccountLock(loanId, LockOwner.valueOf(lockOwner), - ThreadLocalContextUtil.getBusinessDateByType(BusinessDateType.COB_DATE)); + final LocalDate cobBusinessDate = resolveCobBusinessDate(request); + final LoanAccountLock loanAccountLock = new LoanAccountLock(loanId, LockOwner.valueOf(lockOwner), cobBusinessDate); - if (StringUtils.isNotBlank(request.getError())) { + if (request != null && StringUtils.isNotBlank(request.getError())) { loanAccountLock.setError(request.getError(), request.getError()); } loanAccountLockRepository.save(loanAccountLock); return Response.status(Response.Status.ACCEPTED).build(); } + + private static LocalDate resolveCobBusinessDate(final LockRequest request) { + if (request != null && Boolean.TRUE.equals(request.getNullCobBusinessDate())) { + return null; + } + if (request != null && request.getCobBusinessDate() != null) { + return request.getCobBusinessDate(); + } + return ThreadLocalContextUtil.getBusinessDateByType(BusinessDateType.COB_DATE); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/domain/LoanAccountLockRepository.java b/fineract-provider/src/main/java/org/apache/fineract/cob/domain/LoanAccountLockRepository.java index 3724185c46f..333c830a396 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/domain/LoanAccountLockRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/domain/LoanAccountLockRepository.java @@ -22,6 +22,9 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -49,4 +52,19 @@ public interface LoanAccountLockRepository @Override void removeByLockOwnerInAndErrorIsNotNullAndLockPlacedOnCobBusinessDateIsNotNull(List lockOwners); + @Override + @Modifying(clearAutomatically = true) + @Query(""" + DELETE FROM LoanAccountLock lck + WHERE lck.error IS NULL + AND lck.lockPlacedOnCobBusinessDate IS NOT NULL + AND lck.lockOwner IN :lockOwners + AND EXISTS ( + SELECT l FROM Loan l + WHERE l.id = lck.loanId + AND l.lastClosedBusinessDate = lck.lockPlacedOnCobBusinessDate + ) + """) + int deleteOrphanedLocksForProcessedAccounts(@Param("lockOwners") List lockOwners); + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanCOBManagerConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanCOBManagerConfiguration.java index c81e92a44e5..38c679946c1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanCOBManagerConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanCOBManagerConfiguration.java @@ -23,7 +23,9 @@ import org.apache.fineract.cob.COBBusinessStepService; import org.apache.fineract.cob.common.CustomJobParameterResolver; import org.apache.fineract.cob.conditions.BatchManagerCondition; +import org.apache.fineract.cob.domain.LoanAccountLock; import org.apache.fineract.cob.listener.COBExecutionListenerRunner; +import org.apache.fineract.cob.service.AccountLockService; import org.apache.fineract.cob.service.RetrieveLoanIdService; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.infrastructure.jobs.service.JobName; @@ -77,6 +79,9 @@ public class LoanCOBManagerConfiguration { @Autowired private CustomJobParameterResolver customJobParameterResolver; + @Autowired + private AccountLockService loanAccountLockService; + @Bean @StepScope public LoanCOBPartitioner partitioner(@Value("#{stepExecution}") StepExecution stepExecution) { @@ -113,6 +118,17 @@ public StayedLockedLoansTasklet stayedLockedTasklet() { return new StayedLockedLoansTasklet(businessEventNotifierService, retrieveIdService); } + @Bean + public Step unlockProcessedLoansStep() { + return new StepBuilder("Unlock processed loan accounts - Step", jobRepository) + .tasklet(unlockProcessedLoansTasklet(), transactionManager).build(); + } + + @Bean + public UnlockProcessedLoansTasklet unlockProcessedLoansTasklet() { + return new UnlockProcessedLoansTasklet(loanAccountLockService); + } + @Bean(name = "loanCOBJob") public Job loanCOBJob(LoanCOBPartitioner partitioner) { return new JobBuilder(JobName.LOAN_COB.name(), jobRepository) // @@ -120,6 +136,7 @@ public Job loanCOBJob(LoanCOBPartitioner partitioner) { .start(resolveCustomJobParametersStep()) // .next(loanCOBStep(partitioner)) // .next(stayedLockedStep()) // + .next(unlockProcessedLoansStep()) // .incrementer(new RunIdIncrementer()) // .build(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/UnlockProcessedLoansTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/UnlockProcessedLoansTasklet.java new file mode 100644 index 00000000000..ddc6a09d46a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/UnlockProcessedLoansTasklet.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.cob.loan; + +import org.apache.fineract.cob.domain.LoanAccountLock; +import org.apache.fineract.cob.service.AccountLockService; +import org.apache.fineract.cob.tasklet.UnlockProcessedAccountsTasklet; + +public class UnlockProcessedLoansTasklet extends UnlockProcessedAccountsTasklet { + + public UnlockProcessedLoansTasklet(final AccountLockService accountLockService) { + super(accountLockService); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java b/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java index 1e89ee8e402..ccc2d755c22 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java @@ -26,8 +26,8 @@ @Service public class LoanAccountLockService extends AbstractAccountLockService { - public LoanAccountLockService(AccountLockRepository loanAccountLockRepository, - CustomLoanAccountLockRepository customLoanAccountLockRepository) { + public LoanAccountLockService(final AccountLockRepository loanAccountLockRepository, + final CustomLoanAccountLockRepository customLoanAccountLockRepository) { super(loanAccountLockRepository, customLoanAccountLockRepository); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/api/InternalWorkingCapitalAccountLockApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/api/InternalWorkingCapitalAccountLockApiResource.java new file mode 100644 index 00000000000..ed9e8fb73d2 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/api/InternalWorkingCapitalAccountLockApiResource.java @@ -0,0 +1,102 @@ +/** + * 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.cob.api; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +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.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.cob.domain.LockOwner; +import org.apache.fineract.cob.domain.WorkingCapitalAccountLockRepository; +import org.apache.fineract.cob.domain.WorkingCapitalLoanAccountLock; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestBody; + +@Profile(FineractProfiles.TEST) +@Component +@Path("/v1/internal/working-capital-loans") +@Tag(name = "Working Capital Loan Account Lock") +@RequiredArgsConstructor +@Slf4j +public class InternalWorkingCapitalAccountLockApiResource implements InitializingBean { + + private final WorkingCapitalAccountLockRepository workingCapitalAccountLockRepository; + + @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("------------------------------------------------------------"); + } + + @POST + @Path("{loanId}/place-lock/{lockOwner}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @SuppressFBWarnings("SLF4J_SIGN_ONLY_FORMAT") + public Response placeLockOnWorkingCapitalLoanAccount(@Context final UriInfo uriInfo, @PathParam("loanId") final Long loanId, + @PathParam("lockOwner") final String lockOwner, @RequestBody(required = false) final LockRequest request) { + log.warn("------------------------------------------------------------"); + log.warn(" "); + log.warn("Placing lock on working capital loan: {}", loanId); + log.warn(" "); + log.warn("------------------------------------------------------------"); + + final LocalDate cobBusinessDate = resolveCobBusinessDate(request); + final WorkingCapitalLoanAccountLock loanAccountLock = new WorkingCapitalLoanAccountLock(loanId, LockOwner.valueOf(lockOwner), + cobBusinessDate); + + if (request != null && StringUtils.isNotBlank(request.getError())) { + loanAccountLock.setError(request.getError(), request.getError()); + } + workingCapitalAccountLockRepository.save(loanAccountLock); + return Response.status(Response.Status.ACCEPTED).build(); + } + + private static LocalDate resolveCobBusinessDate(final LockRequest request) { + if (request != null && Boolean.TRUE.equals(request.getNullCobBusinessDate())) { + return null; + } + if (request != null && request.getCobBusinessDate() != null) { + return request.getCobBusinessDate(); + } + return ThreadLocalContextUtil.getBusinessDateByType(BusinessDateType.COB_DATE); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/domain/WorkingCapitalAccountLockRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/domain/WorkingCapitalAccountLockRepository.java index b40f1611885..a085234a114 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/domain/WorkingCapitalAccountLockRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/domain/WorkingCapitalAccountLockRepository.java @@ -18,10 +18,31 @@ */ package org.apache.fineract.cob.domain; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface WorkingCapitalAccountLockRepository extends AccountLockRepository, - JpaRepository, JpaSpecificationExecutor {} + JpaRepository, JpaSpecificationExecutor { + + @Override + @Modifying(clearAutomatically = true) + @Query(""" + DELETE FROM WorkingCapitalLoanAccountLock lck + WHERE lck.error IS NULL + AND lck.lockPlacedOnCobBusinessDate IS NOT NULL + AND lck.lockOwner IN :lockOwners + AND EXISTS ( + SELECT l FROM WorkingCapitalLoan l + WHERE l.id = lck.loanId + AND l.lastClosedBusinessDate = lck.lockPlacedOnCobBusinessDate + ) + """) + int deleteOrphanedLocksForProcessedAccounts(@Param("lockOwners") List lockOwners); + +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/UnlockProcessedWorkingCapitalLoansTasklet.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/UnlockProcessedWorkingCapitalLoansTasklet.java new file mode 100644 index 00000000000..389816aaa67 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/UnlockProcessedWorkingCapitalLoansTasklet.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.cob.workingcapitalloan; + +import org.apache.fineract.cob.domain.WorkingCapitalLoanAccountLock; +import org.apache.fineract.cob.service.AccountLockService; +import org.apache.fineract.cob.tasklet.UnlockProcessedAccountsTasklet; + +public class UnlockProcessedWorkingCapitalLoansTasklet extends UnlockProcessedAccountsTasklet { + + public UnlockProcessedWorkingCapitalLoansTasklet(final AccountLockService accountLockService) { + super(accountLockService); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalAccountLockServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalAccountLockServiceImpl.java index d10fd7d2009..74d540e86d7 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalAccountLockServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalAccountLockServiceImpl.java @@ -27,8 +27,8 @@ @Service public class WorkingCapitalAccountLockServiceImpl extends AbstractAccountLockService { - public WorkingCapitalAccountLockServiceImpl(AccountLockRepository loanAccountLockRepository, - CustomLoanAccountLockRepository customLoanAccountLockRepository) { + public WorkingCapitalAccountLockServiceImpl(final AccountLockRepository loanAccountLockRepository, + final CustomLoanAccountLockRepository customLoanAccountLockRepository) { super(loanAccountLockRepository, customLoanAccountLockRepository); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalLoanCOBManagerConfiguration.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalLoanCOBManagerConfiguration.java index de81d31e7b2..9d3ea2bf996 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalLoanCOBManagerConfiguration.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalLoanCOBManagerConfiguration.java @@ -30,6 +30,8 @@ import org.apache.fineract.cob.COBBusinessStepService; import org.apache.fineract.cob.common.CustomJobParameterResolver; import org.apache.fineract.cob.conditions.BatchManagerCondition; +import org.apache.fineract.cob.domain.WorkingCapitalLoanAccountLock; +import org.apache.fineract.cob.service.AccountLockService; import org.apache.fineract.infrastructure.springbatch.PropertyService; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; @@ -57,18 +59,15 @@ public class WorkingCapitalLoanCOBManagerConfiguration { private final JobRepository jobRepository; - private final CustomJobParameterResolver customJobParameterResolver; - private final PlatformTransactionManager transactionManager; private final RemotePartitioningManagerStepBuilderFactory stepBuilderFactory; private final COBBusinessStepService cobBusinessStepService; private final JobOperator jobOperator; - private final DirectChannel inboundRequests; - private final DirectChannel outboundRequests; private final PropertyService propertyService; private final WorkingCapitalLoanRetrieveIdService retrieveIdService; + private final AccountLockService accountLockService; @Bean(WORKING_CAPITAL_LOAN_COB_PARTITIONER) @StepScope @@ -80,12 +79,25 @@ public WorkingCapitalLoanCOBPartitioner workingCapitalLoanCOBPartitioner(@Value( @Bean(WORKING_CAPITAL_JOB_HUMAN_READABLE_NAME) public Job workingCapitalLoanCOBJob(WorkingCapitalLoanCOBPartitioner workingCapitalLoanCOBPartitioner, ExecutionContextPromotionListener customJobParametersPromotionListener) { - return new JobBuilder(WORKING_CAPITAL_LOAN_COB_JOB.name(), jobRepository) - .start(resolveCustomJobParametersForWorkingCapitalStep(customJobParametersPromotionListener)) - .next(workingCapitalLoanCOBStep(workingCapitalLoanCOBPartitioner)).incrementer(new RunIdIncrementer()) // + return new JobBuilder(WORKING_CAPITAL_LOAN_COB_JOB.name(), jobRepository) // + .start(resolveCustomJobParametersForWorkingCapitalStep(customJobParametersPromotionListener)) // + .next(workingCapitalLoanCOBStep(workingCapitalLoanCOBPartitioner)) // + .next(unlockProcessedWorkingCapitalLoansStep()) // + .incrementer(new RunIdIncrementer()) // .build(); } + @Bean + public Step unlockProcessedWorkingCapitalLoansStep() { + return new StepBuilder("Unlock processed working capital loan accounts - Step", jobRepository) + .tasklet(unlockProcessedWorkingCapitalLoansTasklet(), transactionManager).build(); + } + + @Bean + public UnlockProcessedWorkingCapitalLoansTasklet unlockProcessedWorkingCapitalLoansTasklet() { + return new UnlockProcessedWorkingCapitalLoansTasklet(accountLockService); + } + @Bean public WorkingCapitalLoanCOBCustomJobParametersResolverTasklet resolveCustomJobParametersForWorkingCapitalTasklet() { return new WorkingCapitalLoanCOBCustomJobParametersResolverTasklet(customJobParameterResolver); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanAccountLockIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanAccountLockIntegrationTest.java index 38b45f9046c..662511c38c7 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanAccountLockIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanAccountLockIntegrationTest.java @@ -52,7 +52,7 @@ public void checkRetrieveLockedLoanAccountsList() { loanTransactionHelper.disburseLoan(loanId, "20 September 2011", 12000.0); verifyLoanStatus(loanId, LoanStatus.ACTIVE); - Calls.ok(FineractClientHelper.getFineractClient().legacy // + Calls.ok(FineractClientHelper.getFineractClient().loanAccountLockApi // .placeLockOnLoanAccount(loanId, "LOAN_INLINE_COB_PROCESSING", new LockRequest().error("Sample error"))); LoanAccountLockResponseDTO lockResponse = Calls