diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java index 1c2c18715..ec86e22b5 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java @@ -147,6 +147,10 @@ public class JournalEntry extends AbstractAuditableWithUTCDateTimeCustom { return JournalEntryType.DEBIT.getValue().equals(this.type); } + public boolean isCreditEntry() { + return JournalEntryType.CREDIT.getValue().equals(this.type); + } + public void setReversalJournalEntry(final JournalEntry reversalJournalEntry) { this.reversalJournalEntry = reversalJournalEntry; } diff --git a/fineract-avro-schemas/src/main/avro/loan/v1/LoanOwnershipTransferDataV1.avsc b/fineract-avro-schemas/src/main/avro/loan/v1/LoanOwnershipTransferDataV1.avsc index 8251980f2..e2131228c 100644 --- a/fineract-avro-schemas/src/main/avro/loan/v1/LoanOwnershipTransferDataV1.avsc +++ b/fineract-avro-schemas/src/main/avro/loan/v1/LoanOwnershipTransferDataV1.avsc @@ -31,6 +31,14 @@ "string" ] }, + { + "default": null, + "name": "transferExternalGroupId", + "type": [ + "null", + "string" + ] + }, { "default": null, "name": "submittedDate", diff --git a/fineract-core/build.gradle b/fineract-core/build.gradle index 5a46fa767..c05ff7288 100644 --- a/fineract-core/build.gradle +++ b/fineract-core/build.gradle @@ -31,8 +31,8 @@ compileJava.doLast { javaexec { description = 'Performs EclipseLink static weaving of entity classes' def target = source - mainClass = 'org.eclipse.persistence.tools.weaving.jpa.StaticWeave' - args '-persistenceinfo', source, '-classpath', sourceSets.main.runtimeClasspath, source, target + main = 'org.eclipse.persistence.tools.weaving.jpa.StaticWeave' + args '-persistenceinfo', source, source, target classpath sourceSets.main.runtimeClasspath } delete { 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 8b3ec4ad0..3823aff79 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 @@ -3661,6 +3661,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder intermediarySaleLoanToExternalAssetOwner(final Long loanId) { + this.actionName = "INTERMEDIARYSALE"; + this.entityName = "LOAN"; + this.loanId = loanId; + this.href = "/external-asset-owners/transfers/loans/" + loanId; + return this; + } + public CommandWrapperBuilder saleLoanToExternalAssetOwner(final Long loanId) { this.actionName = "SALE"; this.entityName = "LOAN"; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/CommandProcessingResultBuilder.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/CommandProcessingResultBuilder.java index e630ba707..359820d91 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/CommandProcessingResultBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/CommandProcessingResultBuilder.java @@ -43,9 +43,7 @@ public class CommandProcessingResultBuilder { private Long productId; private boolean rollbackTransaction = false; private ExternalId entityExternalId = ExternalId.empty(); - private ExternalId subEntityExternalId = ExternalId.empty(); - private ExternalId loanExternalId = ExternalId.empty(); public CommandProcessingResult build() { diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java index eb58e92d5..bce572650 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java @@ -20,13 +20,18 @@ package org.apache.fineract.test.data; public enum AssetExternalizationErrorMessage { - LOAN_NOT_ACTIVE("Loan is not in active status"), ASSET_OWNED_CANNOT_BE_SOLD( - "This loan cannot be sold, because it is owned by an external asset owner"), ASSET_NOT_OWNED_CANNOT_BE_BOUGHT( - "This loan cannot be bought back, it is not owned by an external asset owner"), BUYBACK_ALREADY_IN_PROGRESS_CANNOT_BE_BOUGHT( - "This loan cannot be bought back, external asset owner buyback transfer is already in progress"), ALREADY_PENDING( - "External asset owner transfer is already in PENDING state for this loan"), SETTLEMENT_DATE_IN_THE_PAST( - "Settlement date cannot be in the past"), INVALID_REQUEST( - "The request was invalid. This typically will happen due to validation errors which are provided."); + LOAN_NOT_ACTIVE("Loan is not in active status"), // + ASSET_OWNED_CANNOT_BE_SOLD("This loan cannot be sold, because it is owned by an external asset owner"), // + ASSET_NOT_OWNED_CANNOT_BE_BOUGHT("This loan cannot be bought back, it is not owned by an external asset owner"), // + BUYBACK_ALREADY_IN_PROGRESS_CANNOT_BE_BOUGHT( + "This loan cannot be bought back, external asset owner buyback transfer is already in progress"), // + ALREADY_PENDING("External asset owner transfer is already in PENDING state for this loan"), // + SETTLEMENT_DATE_IN_THE_PAST("Settlement date cannot be in the past"), // + LOAN_OVERPAID_INVALID("Loan status OVERPAID is not valid for transfer."), // + LOAN_CLOSED_OBLIGATIONS_MET_INVALID("Loan status CLOSED_OBLIGATIONS_MET is not valid for transfer."), // + LOAN_SUBMITTED_AND_PENDING_APPROVAL_INVALID("Loan status SUBMITTED_AND_PENDING_APPROVAL is not valid for transfer."), // + LOAN_APPROVED_INVALID("Loan status APPROVED is not valid for transfer."), // + INVALID_REQUEST("The request was invalid. This typically will happen due to validation errors which are provided."); // public final String value; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization.feature b/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization.feature index eb0f9453e..327c9333a 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization.feature @@ -262,7 +262,7 @@ Feature: Asset Externalization When Admin sets the business date to "10 May 2023" And Customer makes "AUTOPAY" repayment on "10 May 2023" with 1000 EUR transaction amount Then Loan status will be "CLOSED_OBLIGATIONS_MET" - Then Asset externalization transaction with the following data results a 403 error and "LOAN_NOT_ACTIVE" error message + Then Asset externalization transaction with the following data results a 403 error and "LOAN_CLOSED_OBLIGATIONS_MET_INVALID" error message | Transaction type | settlementDate | purchasePriceRatio | | sale | 2023-05-21 | 1 | @@ -276,7 +276,7 @@ Feature: Asset Externalization When Admin sets the business date to "10 May 2023" And Customer makes "AUTOPAY" repayment on "10 May 2023" with 1200 EUR transaction amount Then Loan status will be "OVERPAID" - Then Asset externalization transaction with the following data results a 403 error and "LOAN_NOT_ACTIVE" error message + Then Asset externalization transaction with the following data results a 403 error and "LOAN_OVERPAID_INVALID" error message | Transaction type | settlementDate | purchasePriceRatio | | sale | 2023-05-21 | 1 | @@ -396,7 +396,7 @@ Feature: Asset Externalization When Admin creates a client with random data When Admin creates a new default Loan with date: "1 May 2023" Then Loan status will be "SUBMITTED_AND_PENDING_APPROVAL" - Then Asset externalization transaction with the following data results a 403 error and "LOAN_NOT_ACTIVE" error message + Then Asset externalization transaction with the following data results a 403 error and "LOAN_SUBMITTED_AND_PENDING_APPROVAL_INVALID" error message | Transaction type | settlementDate | purchasePriceRatio | | sale | 2023-05-30 | 1 | @@ -407,7 +407,7 @@ Feature: Asset Externalization When Admin creates a new default Loan with date: "1 May 2023" And Admin successfully approves the loan on "1 May 2023" with "1000" amount and expected disbursement date on "1 May 2023" Then Loan status will be "APPROVED" - Then Asset externalization transaction with the following data results a 403 error and "LOAN_NOT_ACTIVE" error message + Then Asset externalization transaction with the following data results a 403 error and "LOAN_APPROVED_INVALID" error message | Transaction type | settlementDate | purchasePriceRatio | | sale | 2023-05-30 | 1 | diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResource.java b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResource.java index 3f92519b0..e1bfb9747 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResource.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResource.java @@ -220,7 +220,9 @@ public class ExternalAssetOwnersApiResource { private String getResult(Long loanId, String apiRequestBodyAsJson, String commandParam) { final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson); CommandWrapper commandRequest = null; - if (CommandParameterUtil.is(commandParam, "sale")) { + if (CommandParameterUtil.is(commandParam, "intermediarySale")) { + commandRequest = builder.intermediarySaleLoanToExternalAssetOwner(loanId).build(); + } else if (CommandParameterUtil.is(commandParam, "sale")) { commandRequest = builder.saleLoanToExternalAssetOwner(loanId).build(); } else if (CommandParameterUtil.is(commandParam, "buyback")) { commandRequest = builder.buybackLoanToExternalAssetOwner(loanId).build(); diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResourceSwagger.java b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResourceSwagger.java index e8c032d1c..787769f4e 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResourceSwagger.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnersApiResourceSwagger.java @@ -61,6 +61,9 @@ final class ExternalAssetOwnersApiResourceSwagger { @Schema(example = "e1156fbe-38bb-42f8-b491-fca02075f40e") public String transferExternalId; + @Schema(example = "e1156fbe-38bb-42f8-b491-fca02075f40e") + public String transferExternalGroupId; + @Schema(example = "1") public String purchasePriceRatio; @@ -101,6 +104,9 @@ final class ExternalAssetOwnersApiResourceSwagger { @Schema(example = "36efeb06-d835-48a1-99eb-09bd1d348c1e") public String transferExternalId; + @Schema(example = "e1156fbe-38bb-42f8-b491-fca02075f40e") + public String transferExternalGroupId; + @Schema(example = "1.2345678") public String purchasePriceRatio; diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java b/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java index f58aff581..752501c9b 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java @@ -23,16 +23,17 @@ import java.time.LocalDate; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.cob.loan.LoanCOBBusinessStep; import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccountSnapshotBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.investor.config.InvestorModuleIsEnabledCondition; import org.apache.fineract.investor.data.ExternalTransferStatus; import org.apache.fineract.investor.data.ExternalTransferSubStatus; +import org.apache.fineract.investor.domain.ExternalAssetOwner; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferDetails; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMapping; @@ -40,6 +41,8 @@ import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMapping import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository; import org.apache.fineract.investor.domain.LoanOwnershipTransferBusinessEvent; import org.apache.fineract.investor.service.AccountingService; +import org.apache.fineract.investor.service.DelayedSettlementAttributeService; +import org.apache.fineract.investor.service.LoanTransferabilityService; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.springframework.context.annotation.Conditional; import org.springframework.data.domain.Sort; @@ -52,10 +55,16 @@ import org.springframework.stereotype.Component; public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep { public static final LocalDate FUTURE_DATE_9999_12_31 = LocalDate.of(9999, 12, 31); + public static final List PENDING_STATUSES = List.of(ExternalTransferStatus.PENDING_INTERMEDIATE, + ExternalTransferStatus.PENDING); + public static final List BUYBACK_STATUSES = List.of(ExternalTransferStatus.BUYBACK_INTERMEDIATE, + ExternalTransferStatus.BUYBACK); private final ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository; private final ExternalAssetOwnerTransferLoanMappingRepository externalAssetOwnerTransferLoanMappingRepository; private final AccountingService accountingService; private final BusinessEventNotifierService businessEventNotifierService; + private final LoanTransferabilityService loanTransferabilityService; + private final DelayedSettlementAttributeService delayedSettlementAttributeService; @Override public Loan execute(Loan loan) { @@ -66,7 +75,7 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep List transferDataList = externalAssetOwnerTransferRepository.findAll( (root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loanId), criteriaBuilder.equal(root.get("settlementDate"), settlementDate), - root.get("status").in(List.of(ExternalTransferStatus.PENDING, ExternalTransferStatus.BUYBACK)), + root.get("status").in(Stream.concat(PENDING_STATUSES.stream(), BUYBACK_STATUSES.stream()).toList()), criteriaBuilder.greaterThanOrEqualTo(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31)), Sort.by(Sort.Direction.ASC, "id")); int size = transferDataList.size(); @@ -75,6 +84,11 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep ExternalTransferStatus firstTransferStatus = transferDataList.get(0).getStatus(); ExternalTransferStatus secondTransferStatus = transferDataList.get(1).getStatus(); + if (delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId())) { + throw new IllegalStateException(String.format("Delayed Settlement enabled, but found 2 transfers of statuses: %s and %s", + firstTransferStatus, secondTransferStatus)); + } + if (!ExternalTransferStatus.PENDING.equals(firstTransferStatus) || !ExternalTransferStatus.BUYBACK.equals(secondTransferStatus)) { throw new IllegalStateException(String.format("Illegal transfer found. Expected %s and %s, found: %s and %s", @@ -83,9 +97,9 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep handleSameDaySaleAndBuyback(settlementDate, transferDataList, loan); } else if (size == 1) { ExternalAssetOwnerTransfer transfer = transferDataList.get(0); - if (ExternalTransferStatus.PENDING.equals(transfer.getStatus())) { + if (PENDING_STATUSES.contains(transfer.getStatus())) { handleSale(loan, settlementDate, transfer); - } else if (ExternalTransferStatus.BUYBACK.equals(transfer.getStatus())) { + } else if (BUYBACK_STATUSES.contains(transfer.getStatus())) { handleBuyback(loan, settlementDate, transfer); } } @@ -95,7 +109,8 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep } private void handleSale(final Loan loan, final LocalDate settlementDate, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { - ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer = sellAsset(loan, settlementDate, externalAssetOwnerTransfer); + ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer = sellAssetOrDecline(loan, settlementDate, externalAssetOwnerTransfer); + businessEventNotifierService.notifyPostBusinessEvent(new LoanOwnershipTransferBusinessEvent(newExternalAssetOwnerTransfer, loan)); if (!ExternalTransferStatus.DECLINED.equals(newExternalAssetOwnerTransfer.getStatus())) { businessEventNotifierService.notifyPostBusinessEvent(new LoanAccountSnapshotBusinessEvent(loan)); @@ -104,14 +119,16 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep private void handleBuyback(final Loan loan, final LocalDate settlementDate, final ExternalAssetOwnerTransfer buybackExternalAssetOwnerTransfer) { + final ExternalTransferStatus expectedActiveStatus = determineExpectedActiveStatus(buybackExternalAssetOwnerTransfer); + Optional optActiveExternalAssetOwnerTransfer = externalAssetOwnerTransferRepository .findOne((root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loan.getId()), criteriaBuilder.equal(root.get("owner"), buybackExternalAssetOwnerTransfer.getOwner()), - criteriaBuilder.equal(root.get("status"), ExternalTransferStatus.ACTIVE), + criteriaBuilder.equal(root.get("status"), expectedActiveStatus), criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31))); ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer; if (!optActiveExternalAssetOwnerTransfer.isPresent()) { - newExternalAssetOwnerTransfer = createNewEntry(settlementDate, buybackExternalAssetOwnerTransfer, + newExternalAssetOwnerTransfer = createNewEntryAndExpireOldEntry(settlementDate, buybackExternalAssetOwnerTransfer, ExternalTransferStatus.CANCELLED, ExternalTransferSubStatus.UNSOLD, settlementDate, settlementDate); } else { newExternalAssetOwnerTransfer = buybackAsset(loan, settlementDate, buybackExternalAssetOwnerTransfer, @@ -134,26 +151,71 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep return buybackExternalAssetOwnerTransfer; } - private ExternalAssetOwnerTransfer sellAsset(final Loan loan, final LocalDate settlementDate, - ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { - ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer; - if (isTransferable(loan)) { - newExternalAssetOwnerTransfer = createActiveEntry(settlementDate, externalAssetOwnerTransfer); - createActiveMapping(loan.getId(), newExternalAssetOwnerTransfer); - newExternalAssetOwnerTransfer - .setExternalAssetOwnerTransferDetails(createAssetOwnerTransferDetails(loan, newExternalAssetOwnerTransfer)); - accountingService.createJournalEntriesForSaleAssetTransfer(loan, newExternalAssetOwnerTransfer); - } else { - ExternalTransferSubStatus subStatus = ExternalTransferSubStatus.BALANCE_ZERO; - if (MathUtil.nullToDefault(loan.getTotalOverpaid(), BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0) { - subStatus = ExternalTransferSubStatus.BALANCE_NEGATIVE; - } - newExternalAssetOwnerTransfer = createNewEntry(settlementDate, externalAssetOwnerTransfer, ExternalTransferStatus.DECLINED, - subStatus, settlementDate, settlementDate); + private ExternalAssetOwnerTransfer sellAssetOrDecline(final Loan loan, final LocalDate settlementDate, + final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + if (!loanTransferabilityService.isTransferable(loan, externalAssetOwnerTransfer)) { + // Validation fails. Decline asset sell. + ExternalTransferSubStatus declinedSubStatus = loanTransferabilityService.getDeclinedSubStatus(loan); + return declinePendingEntry(loan, settlementDate, externalAssetOwnerTransfer, declinedSubStatus); } + + ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer = sellAsset(loan, settlementDate, externalAssetOwnerTransfer); + createActiveMapping(loan.getId(), newExternalAssetOwnerTransfer); + newExternalAssetOwnerTransfer + .setExternalAssetOwnerTransferDetails(createAssetOwnerTransferDetails(loan, newExternalAssetOwnerTransfer)); + return newExternalAssetOwnerTransfer; } + private ExternalAssetOwnerTransfer sellAsset(final Loan loan, final LocalDate settlementDate, + final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + ExternalAssetOwner previousOwner = determinePreviousOwnerAndCleanupIfNeeded(loan, settlementDate, externalAssetOwnerTransfer); + ExternalTransferStatus activeStatus = determineActiveStatus(externalAssetOwnerTransfer); + + ExternalAssetOwnerTransfer newTransfer = activatePendingEntry(settlementDate, externalAssetOwnerTransfer, activeStatus); + accountingService.createJournalEntriesForSaleAssetTransfer(loan, newTransfer, previousOwner); + return newTransfer; + } + + private ExternalAssetOwner determinePreviousOwnerAndCleanupIfNeeded(final Loan loan, final LocalDate settlementDate, + final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + if (!delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId())) { + // When delayed settlement is disabled, asset is directly sold to investor, and we are the previous owner. + return null; + } + + if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) { + // When delayed settlement is enabled and asset is sold to intermediate, we are the previous owner. + return null; + } + + // When delayed settlement is enabled and asset is sold from intermediate to investor, the intermediate is the + // previous owner. + ExternalAssetOwnerTransfer activeIntermediateTransfer = getActiveIntermediateOrThrow(loan); + expireTransfer(settlementDate, activeIntermediateTransfer); + externalAssetOwnerTransferLoanMappingRepository.deleteByLoanIdAndOwnerTransfer(loan.getId(), activeIntermediateTransfer); + + return activeIntermediateTransfer.getOwner(); + } + + private ExternalTransferStatus determineActiveStatus(final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) { + return ExternalTransferStatus.ACTIVE_INTERMEDIATE; + } + + return ExternalTransferStatus.ACTIVE; + } + + private ExternalAssetOwnerTransfer getActiveIntermediateOrThrow(final Loan loan) { + Optional optionalActiveIntermediateTransfer = externalAssetOwnerTransferRepository + .findOne((root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loan.getId()), + criteriaBuilder.equal(root.get("status"), ExternalTransferStatus.ACTIVE_INTERMEDIATE), + criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31))); + + return optionalActiveIntermediateTransfer + .orElseThrow(() -> new IllegalStateException("Expected a effective transfer of ACTIVE_INTERMEDIATE status to be present.")); + } + private ExternalAssetOwnerTransferDetails createAssetOwnerTransferDetails(Loan loan, ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { ExternalAssetOwnerTransferDetails details = new ExternalAssetOwnerTransferDetails(); @@ -176,10 +238,6 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep externalAssetOwnerTransferLoanMappingRepository.save(externalAssetOwnerTransferLoanMapping); } - private boolean isTransferable(final Loan loan) { - return MathUtil.nullToDefault(loan.getSummary().getTotalOutstanding(), BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0; - } - private void handleSameDaySaleAndBuyback(final LocalDate settlementDate, final List transferDataList, Loan loan) { ExternalAssetOwnerTransfer cancelledPendingTransfer = cancelTransfer(settlementDate, transferDataList.get(0)); @@ -190,11 +248,23 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep private ExternalAssetOwnerTransfer cancelTransfer(final LocalDate settlementDate, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { - return createNewEntry(settlementDate, externalAssetOwnerTransfer, ExternalTransferStatus.CANCELLED, + return createNewEntryAndExpireOldEntry(settlementDate, externalAssetOwnerTransfer, ExternalTransferStatus.CANCELLED, ExternalTransferSubStatus.SAMEDAY_TRANSFERS, settlementDate, settlementDate); } - private ExternalAssetOwnerTransfer createNewEntry(final LocalDate settlementDate, + private ExternalAssetOwnerTransfer activatePendingEntry(final LocalDate settlementDate, + final ExternalAssetOwnerTransfer pendingTransfer, final ExternalTransferStatus activeStatus) { + LocalDate effectiveFrom = settlementDate.plusDays(1); + return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, activeStatus, null, effectiveFrom, FUTURE_DATE_9999_12_31); + } + + private ExternalAssetOwnerTransfer declinePendingEntry(final Loan loan, final LocalDate settlementDate, + final ExternalAssetOwnerTransfer pendingTransfer, ExternalTransferSubStatus subStatus) { + return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, ExternalTransferStatus.DECLINED, subStatus, settlementDate, + settlementDate); + } + + private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDate settlementDate, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, final ExternalTransferStatus status, final ExternalTransferSubStatus subStatus, final LocalDate effectiveDateFrom, final LocalDate effectiveDateTo) { ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); @@ -205,20 +275,26 @@ public class LoanAccountOwnerTransferBusinessStep implements LoanCOBBusinessStep newExternalAssetOwnerTransfer.setSettlementDate(settlementDate); newExternalAssetOwnerTransfer.setLoanId(externalAssetOwnerTransfer.getLoanId()); newExternalAssetOwnerTransfer.setExternalLoanId(externalAssetOwnerTransfer.getExternalLoanId()); + newExternalAssetOwnerTransfer.setExternalGroupId(externalAssetOwnerTransfer.getExternalGroupId()); newExternalAssetOwnerTransfer.setPurchasePriceRatio(externalAssetOwnerTransfer.getPurchasePriceRatio()); newExternalAssetOwnerTransfer.setEffectiveDateFrom(effectiveDateFrom); newExternalAssetOwnerTransfer.setEffectiveDateTo(effectiveDateTo); - externalAssetOwnerTransfer.setEffectiveDateTo(settlementDate); - externalAssetOwnerTransferRepository.save(externalAssetOwnerTransfer); + expireTransfer(settlementDate, externalAssetOwnerTransfer); + return externalAssetOwnerTransferRepository.save(newExternalAssetOwnerTransfer); } - private ExternalAssetOwnerTransfer createActiveEntry(final LocalDate settlementDate, - final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { - LocalDate effectiveFrom = settlementDate.plusDays(1); - return createNewEntry(settlementDate, externalAssetOwnerTransfer, ExternalTransferStatus.ACTIVE, null, effectiveFrom, - FUTURE_DATE_9999_12_31); + private void expireTransfer(final LocalDate settlementDate, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + externalAssetOwnerTransfer.setEffectiveDateTo(settlementDate); + externalAssetOwnerTransferRepository.save(externalAssetOwnerTransfer); + } + + private ExternalTransferStatus determineExpectedActiveStatus(final ExternalAssetOwnerTransfer buybackExternalAssetOwnerTransfer) { + if (ExternalTransferStatus.BUYBACK_INTERMEDIATE == buybackExternalAssetOwnerTransfer.getStatus()) { + return ExternalTransferStatus.ACTIVE_INTERMEDIATE; + } + return ExternalTransferStatus.ACTIVE; } @Override diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/config/LoanAccountOwnerTransferConfiguration.java b/fineract-investor/src/main/java/org/apache/fineract/investor/config/LoanAccountOwnerTransferConfiguration.java new file mode 100644 index 000000000..7d1844492 --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/config/LoanAccountOwnerTransferConfiguration.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.investor.config; + +import org.apache.fineract.investor.service.DelayedSettlementAttributeService; +import org.apache.fineract.investor.service.LoanTransferabilityService; +import org.apache.fineract.investor.service.LoanTransferabilityServiceImpl; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class LoanAccountOwnerTransferConfiguration { + + @Bean + @ConditionalOnMissingBean(LoanTransferabilityService.class) + public LoanTransferabilityService loanTransferabilityService(DelayedSettlementAttributeService delayedSettlementAttributeService) { + return new LoanTransferabilityServiceImpl(delayedSettlementAttributeService); + } +} diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferData.java b/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferData.java index 67027356d..f899650f6 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferData.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferData.java @@ -29,6 +29,7 @@ public class ExternalTransferData { private ExternalTransferLoanData loan; private ExternalTransferDataDetails details; private String transferExternalId; + private String transferExternalGroupId; private String purchasePriceRatio; private LocalDate settlementDate; private ExternalTransferStatus status; diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferRequestParameters.java b/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferRequestParameters.java index 49338e224..6a6e1ef0f 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferRequestParameters.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferRequestParameters.java @@ -25,6 +25,7 @@ public final class ExternalTransferRequestParameters { public static final String SETTLEMENT_DATE = "settlementDate"; public static final String OWNER_EXTERNAL_ID = "ownerExternalId"; public static final String TRANSFER_EXTERNAL_ID = "transferExternalId"; + public static final String TRANSFER_EXTERNAL_GROUP_ID = "transferExternalGroupId"; public static final String PURCHASE_PRICE_RATIO = "purchasePriceRatio"; public static final String DATEFORMAT = "dateFormat"; public static final String LOCALE = "locale"; diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferStatus.java b/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferStatus.java index 74a410cdf..37da59898 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferStatus.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/data/ExternalTransferStatus.java @@ -19,5 +19,5 @@ package org.apache.fineract.investor.data; public enum ExternalTransferStatus { - ACTIVE, DECLINED, PENDING, BUYBACK, CANCELLED + ACTIVE, ACTIVE_INTERMEDIATE, DECLINED, PENDING, PENDING_INTERMEDIATE, BUYBACK, BUYBACK_INTERMEDIATE, CANCELLED } diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerTransfer.java b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerTransfer.java index 9a63df587..7d2b488dd 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerTransfer.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerTransfer.java @@ -79,4 +79,6 @@ public class ExternalAssetOwnerTransfer extends AbstractAuditableWithUTCDateTime @Column(name = "external_loan_id", length = 100) private ExternalId externalLoanId; + @Column(name = "external_group_id", length = 100) + private ExternalId externalGroupId; } diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchedExternalAssetOwner.java b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchedExternalAssetOwner.java index 47ac27321..3269ae4b5 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchedExternalAssetOwner.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchedExternalAssetOwner.java @@ -36,6 +36,7 @@ public class SearchedExternalAssetOwner { private final ExternalId owner; private final ExternalId transferExternalId; + private final ExternalId transferExternalGroupId; private final ExternalTransferStatus status; private final ExternalTransferSubStatus subStatus; diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchingExternalAssetOwnerRepositoryImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchingExternalAssetOwnerRepositoryImpl.java index 25566052f..48033fcbe 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchingExternalAssetOwnerRepositoryImpl.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/search/SearchingExternalAssetOwnerRepositoryImpl.java @@ -94,10 +94,11 @@ public class SearchingExternalAssetOwnerRepositoryImpl implements SearchingExter query.orderBy(orders); query.select(cb.construct(SearchedExternalAssetOwner.class, root.get("id"), root.get("loanId"), root.get("externalLoanId"), - owner.get("externalId"), root.get("externalId"), root.get("status"), root.get("subStatus"), root.get("purchasePriceRatio"), - root.get("settlementDate"), root.get("effectiveDateFrom"), root.get("effectiveDateTo"), details.get("id"), - details.get("totalOutstanding"), details.get("totalPrincipalOutstanding"), details.get("totalInterestOutstanding"), - details.get("totalFeeChargesOutstanding"), details.get("totalPenaltyChargesOutstanding"), details.get("totalOverpaid"))); + owner.get("externalId"), root.get("externalId"), root.get("externalGroupId"), root.get("status"), root.get("subStatus"), + root.get("purchasePriceRatio"), root.get("settlementDate"), root.get("effectiveDateFrom"), root.get("effectiveDateTo"), + details.get("id"), details.get("totalOutstanding"), details.get("totalPrincipalOutstanding"), + details.get("totalInterestOutstanding"), details.get("totalFeeChargesOutstanding"), + details.get("totalPenaltyChargesOutstanding"), details.get("totalOverpaid"))); TypedQuery queryToExecute = entityManager.createQuery(query); return criteriaQueryFactory.readPage(queryToExecute, ExternalAssetOwnerTransfer.class, pageable, spec); diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingService.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingService.java index 437211da0..08f5615fa 100755 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingService.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingService.java @@ -18,12 +18,13 @@ */ package org.apache.fineract.investor.service; +import org.apache.fineract.investor.domain.ExternalAssetOwner; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; import org.apache.fineract.portfolio.loanaccount.domain.Loan; public interface AccountingService { - void createJournalEntriesForSaleAssetTransfer(Loan loan, ExternalAssetOwnerTransfer transfer); + void createJournalEntriesForSaleAssetTransfer(Loan loan, ExternalAssetOwnerTransfer transfer, ExternalAssetOwner previousOwner); void createJournalEntriesForBuybackAssetTransfer(Loan loan, ExternalAssetOwnerTransfer transfer); } diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingServiceImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingServiceImpl.java index 394c53111..2b997c604 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingServiceImpl.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/AccountingServiceImpl.java @@ -31,8 +31,8 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.journalentry.domain.JournalEntry; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; import org.apache.fineract.investor.accounting.journalentry.service.InvestorAccountingHelper; +import org.apache.fineract.investor.domain.ExternalAssetOwner; import org.apache.fineract.investor.domain.ExternalAssetOwnerJournalEntryMapping; import org.apache.fineract.investor.domain.ExternalAssetOwnerJournalEntryMappingRepository; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; @@ -53,25 +53,44 @@ public class AccountingServiceImpl implements AccountingService { private final ExternalAssetOwnerJournalEntryMappingRepository externalAssetOwnerJournalEntryMappingRepository; private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; - private static boolean participateInTransfer(FinancialActivityAccount financialActivityAccount, JournalEntry journalEntry, - JournalEntryType filterType) { - return filterType.getValue().equals(journalEntry.getType()) - && !Objects.equals(financialActivityAccount.getGlAccount().getId(), journalEntry.getGlAccount().getId()); - } - @Override - public void createJournalEntriesForSaleAssetTransfer(final Loan loan, final ExternalAssetOwnerTransfer transfer) { + public void createJournalEntriesForSaleAssetTransfer(final Loan loan, final ExternalAssetOwnerTransfer transfer, + final ExternalAssetOwner previousOwner) { + + final ExternalAssetOwner newOwner = transfer.getOwner(); List journalEntryList = createJournalEntries(loan, transfer, true); createMappingToTransfer(transfer, journalEntryList); - createMappingToOwner(transfer, journalEntryList, JournalEntryType.DEBIT); + + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(AccountingConstants.FinancialActivity.ASSET_TRANSFER.getValue()); + journalEntryList.forEach(journalEntry -> { + if (isOwnedByFinancialActivityAccount(journalEntry, financialActivityAccount)) { + createMappingToOwner(null, journalEntry); + return; + } + + ExternalAssetOwner owner = determineOwnerForSale(journalEntry, loan, previousOwner, newOwner); + createMappingToOwner(owner, journalEntry); + }); } @Override public void createJournalEntriesForBuybackAssetTransfer(final Loan loan, final ExternalAssetOwnerTransfer transfer) { + final ExternalAssetOwner previousOwner = transfer.getOwner(); List journalEntryList = createJournalEntries(loan, transfer, false); createMappingToTransfer(transfer, journalEntryList); - createMappingToOwner(transfer, journalEntryList, - LoanStatus.OVERPAID.equals(loan.getStatus()) ? JournalEntryType.DEBIT : JournalEntryType.CREDIT); + + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(AccountingConstants.FinancialActivity.ASSET_TRANSFER.getValue()); + journalEntryList.forEach(journalEntry -> { + if (isOwnedByFinancialActivityAccount(journalEntry, financialActivityAccount)) { + createMappingToOwner(null, journalEntry); + return; + } + + ExternalAssetOwner owner = determineOwnerForBuyback(journalEntry, loan, previousOwner); + createMappingToOwner(owner, journalEntry); + }); } @NotNull @@ -95,18 +114,52 @@ public class AccountingServiceImpl implements AccountingService { return journalEntryList; } - private void createMappingToOwner(ExternalAssetOwnerTransfer transfer, List journalEntryList, - JournalEntryType filterType) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(AccountingConstants.FinancialActivity.ASSET_TRANSFER.getValue()); - journalEntryList.forEach(journalEntry -> { - if (participateInTransfer(financialActivityAccount, journalEntry, filterType)) { - ExternalAssetOwnerJournalEntryMapping mapping = new ExternalAssetOwnerJournalEntryMapping(); - mapping.setJournalEntry(journalEntry); - mapping.setOwner(transfer.getOwner()); - externalAssetOwnerJournalEntryMappingRepository.saveAndFlush(mapping); + private void createMappingToOwner(final ExternalAssetOwner owner, final JournalEntry journalEntry) { + if (owner == null) { + return; + } + + ExternalAssetOwnerJournalEntryMapping mapping = new ExternalAssetOwnerJournalEntryMapping(); + mapping.setJournalEntry(journalEntry); + mapping.setOwner(owner); + externalAssetOwnerJournalEntryMappingRepository.saveAndFlush(mapping); + } + + private ExternalAssetOwner determineOwnerForSale(final JournalEntry journalEntry, final Loan loan, + final ExternalAssetOwner previousOwner, final ExternalAssetOwner newOwner) { + final boolean isOverpaid = LoanStatus.OVERPAID.equals(loan.getStatus()); + + if (isOverpaid) { + if (journalEntry.isCreditEntry()) { + return newOwner; } - }); + if (journalEntry.isDebitEntry()) { + return previousOwner; + } + } else { + if (journalEntry.isCreditEntry()) { + return previousOwner; + } + if (journalEntry.isDebitEntry()) { + return newOwner; + } + } + + throw new IllegalArgumentException("Given journalEntry has invalid type: " + journalEntry.getType()); + } + + private ExternalAssetOwner determineOwnerForBuyback(final JournalEntry journalEntry, final Loan loan, + final ExternalAssetOwner previousOwner) { + final boolean isOverpaid = LoanStatus.OVERPAID.equals(loan.getStatus()); + if (isOverpaid && journalEntry.isDebitEntry()) { + return previousOwner; + } + + if (!isOverpaid && journalEntry.isCreditEntry()) { + return previousOwner; + } + + return null; } private void createMappingToTransfer(ExternalAssetOwnerTransfer transfer, List journalEntryList) { @@ -211,4 +264,8 @@ public class AccountingServiceImpl implements AccountingService { } return journalEntryList; } + + private boolean isOwnedByFinancialActivityAccount(JournalEntry journalEntry, FinancialActivityAccount financialActivityAccount) { + return Objects.equals(financialActivityAccount.getGlAccount().getId(), journalEntry.getGlAccount().getId()); + } } diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/DelayedSettlementAttributeService.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/DelayedSettlementAttributeService.java new file mode 100644 index 000000000..6d8f2a5b5 --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/DelayedSettlementAttributeService.java @@ -0,0 +1,24 @@ +/** + * 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.investor.service; + +public interface DelayedSettlementAttributeService { + + boolean isEnabled(Long loanProductId); +} diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/DelayedSettlementAttributeServiceImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/DelayedSettlementAttributeServiceImpl.java new file mode 100644 index 000000000..c558f6201 --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/DelayedSettlementAttributeServiceImpl.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.investor.service; + +import static org.apache.fineract.investor.data.attribute.SettlementModelExternalAssetOwnerLoanProductAttribute.DELAYED_SETTLEMENT; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.investor.data.ExternalTransferLoanProductAttributesData; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DelayedSettlementAttributeServiceImpl implements DelayedSettlementAttributeService { + + private final ExternalAssetOwnerLoanProductAttributesReadService externalAssetOwnerLoanProductAttributesReadService; + + @Override + public boolean isEnabled(final Long loanProductId) { + Page attributesDataPage = externalAssetOwnerLoanProductAttributesReadService + .retrieveAllLoanProductAttributesByLoanProductId(loanProductId, DELAYED_SETTLEMENT.getAttributeKey()); + String attributeValue = attributesDataPage.getPageItems().stream().findFirst() + .map(ExternalTransferLoanProductAttributesData::getAttributeValue).orElse(null); + return DELAYED_SETTLEMENT.getAttributeValue().equals(attributeValue); + } +} diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersTransferMapper.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersTransferMapper.java index 61491ac65..fe1931f2e 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersTransferMapper.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersTransferMapper.java @@ -37,6 +37,7 @@ public interface ExternalAssetOwnersTransferMapper { @Mapping(target = "loan.loanId", source = "loanId") @Mapping(target = "loan.externalId", source = "externalLoanId") @Mapping(target = "transferExternalId", source = "externalId") + @Mapping(target = "transferExternalGroupId", source = "externalGroupId") @Mapping(target = "effectiveFrom", source = "effectiveDateFrom") @Mapping(target = "effectiveTo", source = "effectiveDateTo") @Mapping(target = "purchasePriceRatio", source = "purchasePriceRatio") diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteService.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteService.java index 8e5f7bdf0..146a26421 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteService.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteService.java @@ -23,6 +23,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; public interface ExternalAssetOwnersWriteService { + CommandProcessingResult intermediarySaleLoanByLoanId(JsonCommand jsonCommand); + CommandProcessingResult saleLoanByLoanId(JsonCommand command); CommandProcessingResult buybackLoanByLoanId(JsonCommand command); diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java index c4790b920..d1712ebed 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java @@ -18,8 +18,12 @@ */ package org.apache.fineract.investor.service; +import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING; +import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING_INTERMEDIATE; import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.ACTIVE; +import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.CLOSED_OBLIGATIONS_MET; +import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.OVERPAID; import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.TRANSFER_IN_PROGRESS; import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.TRANSFER_ON_HOLD; @@ -35,9 +39,10 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; -import org.apache.fineract.cob.data.LoanIdAndExternalIdAndStatus; +import org.apache.fineract.cob.data.LoanDataForExternalTransfer; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; @@ -70,26 +75,55 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW private static final LocalDate FUTURE_DATE_9999_12_31 = LocalDate.of(9999, 12, 31); private static final List ACTIVE_LOAN_STATUSES = List.of(ACTIVE, TRANSFER_IN_PROGRESS, TRANSFER_ON_HOLD); + private static final List VALID_DELAYED_SETTLEMENT_LOAN_STATUSES_BUYBACK_AND_SALE = List.of(ACTIVE, TRANSFER_IN_PROGRESS, + TRANSFER_ON_HOLD, OVERPAID, CLOSED_OBLIGATIONS_MET); private static final List BUYBACK_READY_STATUSES = List.of(ExternalTransferStatus.PENDING, ExternalTransferStatus.ACTIVE); + private static final List BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT = List + .of(ExternalTransferStatus.ACTIVE_INTERMEDIATE, ExternalTransferStatus.ACTIVE); private final ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository; private final ExternalAssetOwnerRepository externalAssetOwnerRepository; private final FromJsonHelper fromApiJsonHelper; private final LoanRepository loanRepository; + private final DelayedSettlementAttributeService delayedSettlementAttributeService; + + @Override + @Transactional + public CommandProcessingResult intermediarySaleLoanByLoanId(JsonCommand command) { + final JsonElement json = fromApiJsonHelper.parse(command.json()); + validateIntermediarySaleRequestBody(command.json()); + Long loanId = command.getLoanId(); + LoanDataForExternalTransfer loanDataForExternalTransfer = fetchAndValidateLoanDataForExternalTransfer(loanId); + if (!delayedSettlementAttributeService.isEnabled(loanDataForExternalTransfer.getLoanProductId())) { + throw new ExternalAssetOwnerInitiateTransferException( + String.format("Delayed Settlement Configuration is not enabled for the loan product: %s", + loanDataForExternalTransfer.getLoanProductShortName())); + } + ExternalId externalId = getTransferExternalIdFromJson(json); + validateExternalId(externalId); + validateLoanStatusIntermediarySale(loanDataForExternalTransfer); + ExternalAssetOwnerTransfer intermediarySaleTransfer = createIntermediarySaleTransfer(loanId, json, + loanDataForExternalTransfer.getExternalId()); + validateIntermediarySale(intermediarySaleTransfer); + externalAssetOwnerTransferRepository.saveAndFlush(intermediarySaleTransfer); + return buildResponseData(intermediarySaleTransfer); + } @Override @Transactional public CommandProcessingResult saleLoanByLoanId(JsonCommand command) { final JsonElement json = fromApiJsonHelper.parse(command.json()); + final LoanDataForExternalTransfer loanDataForExternalTransfer = fetchAndValidateLoanDataForExternalTransfer(command.getLoanId()); + final boolean isDelayedSettlementEnabled = delayedSettlementAttributeService + .isEnabled(loanDataForExternalTransfer.getLoanProductId()); validateSaleRequestBody(command.json()); ExternalId externalId = getTransferExternalIdFromJson(json); validateExternalId(externalId); Long loanId = command.getLoanId(); - LoanIdAndExternalIdAndStatus loanIdAndExternalIdAndStatus = fetchLoanDetails(loanId); - validateLoanStatus(loanIdAndExternalIdAndStatus); + validateLoanStatus(loanDataForExternalTransfer, isDelayedSettlementEnabled); ExternalAssetOwnerTransfer externalAssetOwnerTransfer = createSaleTransfer(loanId, json, - loanIdAndExternalIdAndStatus.getExternalId()); - validateSale(externalAssetOwnerTransfer); + loanDataForExternalTransfer.getExternalId()); + validateSale(externalAssetOwnerTransfer, isDelayedSettlementEnabled); externalAssetOwnerTransferRepository.saveAndFlush(externalAssetOwnerTransfer); return buildResponseData(externalAssetOwnerTransfer); } @@ -99,13 +133,13 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW public CommandProcessingResult buybackLoanByLoanId(JsonCommand command) { final JsonElement json = fromApiJsonHelper.parse(command.json()); validateBuybackRequestBody(command.json()); - Long loanId = command.getLoanId(); - validateLoan(loanId); + LoanDataForExternalTransfer loanDataForExternalTransfer = fetchAndValidateLoanDataForExternalTransfer(command.getLoanId()); LocalDate settlementDate = getSettlementDateFromJson(json); ExternalId externalId = getTransferExternalIdFromJson(json); validateSettlementDate(settlementDate); validateExternalId(externalId); - ExternalAssetOwnerTransfer effectiveTransfer = fetchAndValidateEffectiveTransferForBuyback(loanId, settlementDate); + ExternalAssetOwnerTransfer effectiveTransfer = fetchAndValidateEffectiveTransferForBuyback(loanDataForExternalTransfer, + settlementDate); ExternalAssetOwnerTransfer externalAssetOwnerTransfer = createBuybackTransfer(effectiveTransfer, settlementDate, externalId); externalAssetOwnerTransferRepository.saveAndFlush(externalAssetOwnerTransfer); return buildResponseData(externalAssetOwnerTransfer); @@ -120,10 +154,8 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW } } - private void validateLoan(Long loanId) { - if (!loanRepository.existsById(loanId)) { - throw new LoanNotFoundException(loanId); - } + private LoanDataForExternalTransfer fetchAndValidateLoanDataForExternalTransfer(Long loanId) { + return loanRepository.findLoanDataForExternalTransferByLoanId(loanId).orElseThrow(() -> new LoanNotFoundException(loanId)); } @Override @@ -136,10 +168,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW return buildResponseData(cancelTransfer); } - private void validateEffectiveTransferForSale(final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { - List effectiveTransfers = externalAssetOwnerTransferRepository - .findEffectiveTransfersOrderByIdDesc(externalAssetOwnerTransfer.getLoanId(), DateUtils.getBusinessLocalDate()); - + private void validateEffectiveTransferForSale(final List effectiveTransfers) { if (effectiveTransfers.size() == 2) { throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer"); } else if (effectiveTransfers.size() == 1) { @@ -156,9 +185,47 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW } } - private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForBuyback(final Long loanId, final LocalDate settlementDate) { + private void validateEffectiveTransferForDelayedSettlementSale(final List effectiveTransfers) { + if (effectiveTransfers.size() > 1) { + throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer"); + } else if (effectiveTransfers.size() == 1) { + if (!ACTIVE_INTERMEDIATE.equals(effectiveTransfers.get(0).getStatus())) { + throw new ExternalAssetOwnerInitiateTransferException( + "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state."); + } + } else { + throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, no effective transfer found."); + } + } + + private void validateEffectiveTransferForIntermediarySale(final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { List effectiveTransfers = externalAssetOwnerTransferRepository - .findEffectiveTransfersOrderByIdDesc(loanId, DateUtils.getBusinessLocalDate()); + .findEffectiveTransfersOrderByIdDesc(externalAssetOwnerTransfer.getLoanId(), DateUtils.getBusinessLocalDate()); + + if (effectiveTransfers.size() > 1) { + throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer"); + } else if (effectiveTransfers.size() == 1) { + if (PENDING_INTERMEDIATE.equals(effectiveTransfers.get(0).getStatus())) { + throw new ExternalAssetOwnerInitiateTransferException( + "External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan"); + } else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.get(0).getStatus())) { + throw new ExternalAssetOwnerInitiateTransferException( + "This loan cannot be sold, because it is owned by an external asset owner"); + } else { + throw new ExternalAssetOwnerInitiateTransferException(String.format( + "This loan cannot be sold, because it is incorrect state! (transferId = %s)", effectiveTransfers.get(0).getId())); + } + } + } + + private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForBuyback( + final LoanDataForExternalTransfer loanDataForExternalTransfer, final LocalDate settlementDate) { + if (delayedSettlementAttributeService.isEnabled(loanDataForExternalTransfer.getLoanProductId())) { + return fetchAndValidateEffectiveTransferForBuybackWithDelayedSettlement(loanDataForExternalTransfer, settlementDate); + } + + List effectiveTransfers = externalAssetOwnerTransferRepository + .findEffectiveTransfersOrderByIdDesc(loanDataForExternalTransfer.getId(), DateUtils.getBusinessLocalDate()); if (effectiveTransfers.size() == 0) { throw new ExternalAssetOwnerInitiateTransferException( @@ -179,6 +246,39 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW return effectiveTransfers.get(0); } + private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForBuybackWithDelayedSettlement( + final LoanDataForExternalTransfer loanDataForExternalTransfer, final LocalDate settlementDate) { + List effectiveTransfers = externalAssetOwnerTransferRepository + .findEffectiveTransfersOrderByIdDesc(loanDataForExternalTransfer.getId(), DateUtils.getBusinessLocalDate()); + + if (effectiveTransfers.isEmpty()) { + throw new ExternalAssetOwnerInitiateTransferException( + "This loan cannot be bought back, it is not owned by an external asset owner"); + } + + Set effectiveTransferStatuses = effectiveTransfers.stream().map(ExternalAssetOwnerTransfer::getStatus) + .collect(Collectors.toSet()); + + if (Set.of(ExternalTransferStatus.ACTIVE_INTERMEDIATE, ExternalTransferStatus.PENDING).equals(effectiveTransferStatuses)) { + throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be bought back, external asset owner sale is pending"); + } else if (Set.of(ExternalTransferStatus.ACTIVE_INTERMEDIATE, ExternalTransferStatus.BUYBACK_INTERMEDIATE) + .equals(effectiveTransferStatuses) + || Set.of(ExternalTransferStatus.ACTIVE, ExternalTransferStatus.BUYBACK).equals(effectiveTransferStatuses)) { + throw new ExternalAssetOwnerInitiateTransferException( + "This loan cannot be bought back, external asset owner buyback transfer is already in progress"); + } else if (!BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT.contains(effectiveTransfers.get(0).getStatus())) { + throw new ExternalAssetOwnerInitiateTransferException( + String.format("This loan cannot be bought back, effective transfer is not in right state: %s", + effectiveTransfers.get(0).getStatus())); + } else if (DateUtils.isBefore(settlementDate, effectiveTransfers.get(0).getSettlementDate())) { + throw new ExternalAssetOwnerInitiateTransferException( + String.format("This loan cannot be bought back, settlement date is earlier than effective transfer settlement date: %s", + effectiveTransfers.get(0).getSettlementDate())); + } + + return effectiveTransfers.get(0); + } + private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForCancel(final Long transferId) { ExternalAssetOwnerTransfer selectedTransfer = externalAssetOwnerTransferRepository.findById(transferId) .orElseThrow(() -> new ExternalAssetOwnerInitiateTransferException( @@ -206,10 +306,9 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW ExternalAssetOwnerTransfer externalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); externalAssetOwnerTransfer.setExternalId(externalId); externalAssetOwnerTransfer.setOwner(effectiveTransfer.getOwner()); - externalAssetOwnerTransfer.setStatus(ExternalTransferStatus.BUYBACK); + externalAssetOwnerTransfer.setStatus(determineStatusAfterBuyback(effectiveTransfer)); externalAssetOwnerTransfer.setLoanId(effectiveTransfer.getLoanId()); externalAssetOwnerTransfer.setExternalLoanId(effectiveTransfer.getExternalLoanId()); - externalAssetOwnerTransfer.setOwner(effectiveTransfer.getOwner()); externalAssetOwnerTransfer.setSettlementDate(settlementDate); externalAssetOwnerTransfer.setEffectiveDateFrom(effectiveDateFrom); externalAssetOwnerTransfer.setEffectiveDateTo(FUTURE_DATE_9999_12_31); @@ -217,6 +316,16 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW return externalAssetOwnerTransfer; } + private ExternalTransferStatus determineStatusAfterBuyback(ExternalAssetOwnerTransfer effectiveTransfer) { + return switch (effectiveTransfer.getStatus()) { + case PENDING -> ExternalTransferStatus.BUYBACK; + case ACTIVE -> ExternalTransferStatus.BUYBACK; + case ACTIVE_INTERMEDIATE -> ExternalTransferStatus.BUYBACK_INTERMEDIATE; + default -> throw new ExternalAssetOwnerInitiateTransferException(String.format( + "This loan cannot be bought back, effective transfer is not in right state: %s", effectiveTransfer.getStatus())); + }; + } + private ExternalAssetOwnerTransfer createCancelTransfer(ExternalAssetOwnerTransfer effectiveTransfer) { ExternalAssetOwnerTransfer externalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); externalAssetOwnerTransfer.setExternalId(effectiveTransfer.getExternalId()); @@ -224,6 +333,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW externalAssetOwnerTransfer.setSubStatus(ExternalTransferSubStatus.USER_REQUESTED); externalAssetOwnerTransfer.setLoanId(effectiveTransfer.getLoanId()); externalAssetOwnerTransfer.setExternalLoanId(effectiveTransfer.getExternalLoanId()); + externalAssetOwnerTransfer.setExternalGroupId(effectiveTransfer.getExternalGroupId()); externalAssetOwnerTransfer.setOwner(effectiveTransfer.getOwner()); externalAssetOwnerTransfer.setSettlementDate(effectiveTransfer.getSettlementDate()); externalAssetOwnerTransfer.setEffectiveDateFrom(effectiveTransfer.getEffectiveDateFrom()); @@ -241,9 +351,22 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW .build(); } - private void validateSale(ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + private void validateSale(ExternalAssetOwnerTransfer externalAssetOwnerTransfer, boolean isDelayedSettlementEnabled) { validateSettlementDate(externalAssetOwnerTransfer); - validateEffectiveTransferForSale(externalAssetOwnerTransfer); + + final List effectiveTransfers = externalAssetOwnerTransferRepository + .findEffectiveTransfersOrderByIdDesc(externalAssetOwnerTransfer.getLoanId(), DateUtils.getBusinessLocalDate()); + + if (isDelayedSettlementEnabled) { + validateEffectiveTransferForDelayedSettlementSale(effectiveTransfers); + } else { + validateEffectiveTransferForSale(effectiveTransfers); + } + } + + private void validateIntermediarySale(ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + validateSettlementDate(externalAssetOwnerTransfer); + validateEffectiveTransferForIntermediarySale(externalAssetOwnerTransfer); } private void validateSettlementDate(ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { @@ -256,9 +379,25 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW } } - private void validateLoanStatus(LoanIdAndExternalIdAndStatus entity) { - if (!ACTIVE_LOAN_STATUSES.contains(LoanStatus.fromInt(entity.getLoanStatus()))) { - throw new ExternalAssetOwnerInitiateTransferException("Loan is not in active status"); + private void validateLoanStatus(LoanDataForExternalTransfer loanDataForExternalTransfer, boolean isDelayedSettlementEnabled) { + LoanStatus loanStatus = LoanStatus.fromInt(loanDataForExternalTransfer.getLoanStatus()); + if (!getValidLoanStatusList(isDelayedSettlementEnabled).contains(loanStatus)) { + throw new ExternalAssetOwnerInitiateTransferException(String.format("Loan status %s is not valid for transfer.", loanStatus)); + } + } + + private void validateLoanStatusIntermediarySale(LoanDataForExternalTransfer loanDataForExternalTransfer) { + LoanStatus loanStatus = LoanStatus.fromInt(loanDataForExternalTransfer.getLoanStatus()); + if (!ACTIVE_LOAN_STATUSES.contains(loanStatus)) { + throw new ExternalAssetOwnerInitiateTransferException(String.format("Loan status %s is not valid for transfer.", loanStatus)); + } + } + + private List getValidLoanStatusList(boolean isDelayedSettlementEnabled) { + if (isDelayedSettlementEnabled) { + return VALID_DELAYED_SETTLEMENT_LOAN_STATUSES_BUYBACK_AND_SALE; + } else { + return ACTIVE_LOAN_STATUSES; } } @@ -276,14 +415,33 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW externalAssetOwnerTransfer.setEffectiveDateTo(FUTURE_DATE_9999_12_31); externalAssetOwnerTransfer.setLoanId(loanId); externalAssetOwnerTransfer.setExternalLoanId(externalLoanId); + externalAssetOwnerTransfer.setExternalGroupId(getTransferExternalGroupIdFromJson(json)); + return externalAssetOwnerTransfer; + } + + private ExternalAssetOwnerTransfer createIntermediarySaleTransfer(Long loanId, JsonElement json, ExternalId externalLoanId) { + ExternalAssetOwnerTransfer externalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); + LocalDate effectiveFrom = ThreadLocalContextUtil.getBusinessDate(); + + ExternalAssetOwner owner = getOwner(json); + externalAssetOwnerTransfer.setOwner(owner); + externalAssetOwnerTransfer.setExternalId(getTransferExternalIdFromJson(json)); + externalAssetOwnerTransfer.setStatus(PENDING_INTERMEDIATE); + externalAssetOwnerTransfer.setPurchasePriceRatio(getPurchasePriceRatioFromJson(json)); + externalAssetOwnerTransfer.setSettlementDate(getSettlementDateFromJson(json)); + externalAssetOwnerTransfer.setEffectiveDateFrom(effectiveFrom); + externalAssetOwnerTransfer.setEffectiveDateTo(FUTURE_DATE_9999_12_31); + externalAssetOwnerTransfer.setLoanId(loanId); + externalAssetOwnerTransfer.setExternalLoanId(externalLoanId); + externalAssetOwnerTransfer.setExternalGroupId(getTransferExternalGroupIdFromJson(json)); return externalAssetOwnerTransfer; } private void validateSaleRequestBody(String apiRequestBodyAsJson) { - final Set requestParameters = new HashSet<>( - Arrays.asList(ExternalTransferRequestParameters.SETTLEMENT_DATE, ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, - ExternalTransferRequestParameters.TRANSFER_EXTERNAL_ID, ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, - ExternalTransferRequestParameters.DATEFORMAT, ExternalTransferRequestParameters.LOCALE)); + final Set requestParameters = new HashSet<>(Arrays.asList(ExternalTransferRequestParameters.SETTLEMENT_DATE, + ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, ExternalTransferRequestParameters.TRANSFER_EXTERNAL_ID, + ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID, ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, + ExternalTransferRequestParameters.DATEFORMAT, ExternalTransferRequestParameters.LOCALE)); final Type typeOfMap = new TypeToken>() { }.getType(); @@ -308,6 +466,51 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW LocalDate settlementDate = fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE, json); baseDataValidator.reset().parameter(ExternalTransferRequestParameters.SETTLEMENT_DATE).value(settlementDate).notNull(); + final String transferExternalGroupId = fromApiJsonHelper + .extractStringNamed(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID, json); + baseDataValidator.reset().parameter(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID).value(transferExternalGroupId) + .ignoreIfNull().notExceedingLengthOf(100); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } + + private void validateIntermediarySaleRequestBody(String apiRequestBodyAsJson) { + final Set requestParameters = new HashSet<>(Arrays.asList(ExternalTransferRequestParameters.SETTLEMENT_DATE, + ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, ExternalTransferRequestParameters.TRANSFER_EXTERNAL_ID, + ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID, ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, + ExternalTransferRequestParameters.DATEFORMAT, ExternalTransferRequestParameters.LOCALE)); + final Type typeOfMap = new TypeToken>() { + + }.getType(); + fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, apiRequestBodyAsJson, requestParameters); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loantransfer"); + final JsonElement json = fromApiJsonHelper.parse(apiRequestBodyAsJson); + + String ownerExternalId = fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, json); + baseDataValidator.reset().parameter(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID).value(ownerExternalId).notBlank() + .notExceedingLengthOf(100); + + String transferExternalId = fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_ID, json); + baseDataValidator.reset().parameter(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_ID).value(transferExternalId).ignoreIfNull() + .notExceedingLengthOf(100); + + String purchasePriceRatio = fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, json); + baseDataValidator.reset().parameter(ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO).value(purchasePriceRatio).notBlank() + .notExceedingLengthOf(50); + + LocalDate settlementDate = fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE, json); + baseDataValidator.reset().parameter(ExternalTransferRequestParameters.SETTLEMENT_DATE).value(settlementDate).notNull(); + + String transferExternalGroupId = fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID, + json); + baseDataValidator.reset().parameter(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID).value(transferExternalGroupId) + .ignoreIfNull().notExceedingLengthOf(100); + if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", dataValidationErrors); @@ -352,6 +555,12 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW return StringUtils.isEmpty(transferExternalId) ? ExternalId.generate() : ExternalIdFactory.produce(transferExternalId); } + private ExternalId getTransferExternalGroupIdFromJson(JsonElement json) { + String transferExternalGroupId = fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID, + json); + return StringUtils.isEmpty(transferExternalGroupId) ? null : ExternalIdFactory.produce(transferExternalGroupId); + } + private String getPurchasePriceRatioFromJson(JsonElement json) { return fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, json); } @@ -368,10 +577,4 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW externalAssetOwner.setExternalId(ExternalIdFactory.produce(externalId)); return externalAssetOwnerRepository.saveAndFlush(externalAssetOwner); } - - private LoanIdAndExternalIdAndStatus fetchLoanDetails(Long loanId) { - Optional loanIdAndExternalIdAndStatusResult = loanRepository - .findLoanIdAndExternalIdAndStatusByLoanId(loanId); - return loanIdAndExternalIdAndStatusResult.orElseThrow(() -> new LoanNotFoundException(loanId)); - } } diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/IntermediarySaleToExternalAssetOwnerHandler.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/IntermediarySaleToExternalAssetOwnerHandler.java new file mode 100644 index 000000000..9c41b5bd3 --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/IntermediarySaleToExternalAssetOwnerHandler.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.investor.service; + +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.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +@CommandType(entity = "LOAN", action = "INTERMEDIARYSALE") +public class IntermediarySaleToExternalAssetOwnerHandler implements NewCommandSourceHandler { + + private final ExternalAssetOwnersWriteService externalAssetOwnersWriteService; + + @Override + public CommandProcessingResult processCommand(JsonCommand command) { + return externalAssetOwnersWriteService.intermediarySaleLoanByLoanId(command); + } + +} diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceImpl.java index 9d5517cc7..c638a06d8 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceImpl.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceImpl.java @@ -19,10 +19,14 @@ package org.apache.fineract.investor.service; import static org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate; +import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE; +import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK; +import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferStatus.CANCELLED; import static org.apache.fineract.investor.data.ExternalTransferStatus.DECLINED; import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING; +import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferSubStatus.BALANCE_NEGATIVE; import static org.apache.fineract.investor.data.ExternalTransferSubStatus.BALANCE_ZERO; import static org.apache.fineract.investor.data.ExternalTransferSubStatus.SAMEDAY_TRANSFERS; @@ -37,7 +41,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccountSnapshotBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; -import org.apache.fineract.investor.data.ExternalTransferStatus; import org.apache.fineract.investor.data.ExternalTransferSubStatus; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferDetails; @@ -64,25 +67,21 @@ public class LoanAccountOwnerTransferServiceImpl implements LoanAccountOwnerTran @Override public void handleLoanClosedOrOverpaid(Loan loan) { Long loanId = loan.getId(); - List transferDataList = findAllPendingOrBuybackTransfers(loanId); + List transferDataList = findAllPendingOrBuybackOrIntermediateTransfers(loanId); - if (transferDataList.size() == 2) { - ExternalTransferSubStatus subStatus; - ExternalAssetOwnerTransfer pendingSaleTransfer = transferDataList.get(0); - ExternalAssetOwnerTransfer pendingBuybackTransfer = transferDataList.get(1); + if (transferDataList.size() > 1) { if (isSameDayTransfers(transferDataList)) { - subStatus = SAMEDAY_TRANSFERS; - cancelTransfer(loan, pendingSaleTransfer, subStatus); - cancelTransfer(loan, pendingBuybackTransfer, subStatus); + transferDataList.forEach(externalAssetOwnerTransfer -> cancelTransfer(loan, externalAssetOwnerTransfer, SAMEDAY_TRANSFERS)); } else { - declineTransfer(loan, pendingSaleTransfer); - cancelTransfer(loan, pendingBuybackTransfer, UNSOLD); + // decline first and cancel the rest + declineTransfer(loan, transferDataList.get(0)); + transferDataList.stream().skip(1).forEach(assetOwnerTransfer -> cancelTransfer(loan, assetOwnerTransfer, UNSOLD)); } } else if (transferDataList.size() == 1) { ExternalAssetOwnerTransfer transfer = transferDataList.get(0); - if (PENDING.equals(transfer.getStatus())) { + if (PENDING.equals(transfer.getStatus()) || PENDING_INTERMEDIATE.equals(transfer.getStatus())) { declineTransfer(loan, transfer); - } else if (BUYBACK.equals(transfer.getStatus())) { + } else if (BUYBACK.equals(transfer.getStatus()) || BUYBACK_INTERMEDIATE.equals(transfer.getStatus())) { executePendingBuybackTransfer(loan, transfer); } } @@ -103,7 +102,7 @@ public class LoanAccountOwnerTransferServiceImpl implements LoanAccountOwnerTran } private void executePendingBuybackTransfer(final Loan loan, ExternalAssetOwnerTransfer buybackTransfer) { - ExternalAssetOwnerTransfer activeTransfer = findActiveTransfer(loan, buybackTransfer); + ExternalAssetOwnerTransfer activeTransfer = findActiveOrActiveIntermediateTransfer(loan, buybackTransfer); updateActiveTransfer(activeTransfer); buybackTransfer = updatePendingBuybackTransfer(loan, buybackTransfer); @@ -119,6 +118,7 @@ public class LoanAccountOwnerTransferServiceImpl implements LoanAccountOwnerTran ExternalAssetOwnerTransfer cancelledTransfer = new ExternalAssetOwnerTransfer(); cancelledTransfer.setOwner(pendingTransfer.getOwner()); cancelledTransfer.setExternalId(pendingTransfer.getExternalId()); + cancelledTransfer.setExternalGroupId(pendingTransfer.getExternalGroupId()); cancelledTransfer.setStatus(CANCELLED); cancelledTransfer.setSubStatus(subStatus); cancelledTransfer.setSettlementDate(pendingTransfer.getSettlementDate()); @@ -134,6 +134,7 @@ public class LoanAccountOwnerTransferServiceImpl implements LoanAccountOwnerTran ExternalAssetOwnerTransfer declinedTransfer = new ExternalAssetOwnerTransfer(); declinedTransfer.setOwner(pendingSaleTransfer.getOwner()); declinedTransfer.setExternalId(pendingSaleTransfer.getExternalId()); + declinedTransfer.setExternalGroupId(pendingSaleTransfer.getExternalGroupId()); declinedTransfer.setStatus(DECLINED); declinedTransfer.setSubStatus(isBiggerThanZero(loan.getTotalOverpaid()) ? BALANCE_NEGATIVE : BALANCE_ZERO); declinedTransfer.setSettlementDate(pendingSaleTransfer.getSettlementDate()); @@ -176,20 +177,20 @@ public class LoanAccountOwnerTransferServiceImpl implements LoanAccountOwnerTran return details; } - private ExternalAssetOwnerTransfer findActiveTransfer(Loan loan, ExternalAssetOwnerTransfer buybackTransfer) { + private ExternalAssetOwnerTransfer findActiveOrActiveIntermediateTransfer(Loan loan, ExternalAssetOwnerTransfer buybackTransfer) { return externalAssetOwnerTransferRepository .findOne((root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loan.getId()), criteriaBuilder.equal(root.get("owner"), buybackTransfer.getOwner()), - criteriaBuilder.equal(root.get("status"), ExternalTransferStatus.ACTIVE), + root.get("status").in(List.of(ACTIVE, ACTIVE_INTERMEDIATE)), criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31))) .orElseThrow(); } - private List findAllPendingOrBuybackTransfers(Long loanId) { + private List findAllPendingOrBuybackOrIntermediateTransfers(Long loanId) { return externalAssetOwnerTransferRepository .findAll( (root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loanId), - root.get("status").in(List.of(PENDING, BUYBACK)), + root.get("status").in(List.of(PENDING, BUYBACK, PENDING_INTERMEDIATE, BUYBACK_INTERMEDIATE)), criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31)), Sort.by(Sort.Direction.ASC, "id")); } @@ -199,6 +200,6 @@ public class LoanAccountOwnerTransferServiceImpl implements LoanAccountOwnerTran } private static boolean isSameDayTransfers(List transferDataList) { - return Objects.equals(transferDataList.get(0).getSettlementDate(), transferDataList.get(1).getSettlementDate()); + return (transferDataList.stream().map(ExternalAssetOwnerTransfer::getSettlementDate).distinct().count() == 1); } } diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanTransferabilityService.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanTransferabilityService.java new file mode 100644 index 000000000..0863436ab --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanTransferabilityService.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.investor.service; + +import org.apache.fineract.investor.data.ExternalTransferSubStatus; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; + +public interface LoanTransferabilityService { + + boolean isTransferable(Loan loan, ExternalAssetOwnerTransfer externalAssetOwnerTransfer); + + ExternalTransferSubStatus getDeclinedSubStatus(Loan loan); +} diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanTransferabilityServiceImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanTransferabilityServiceImpl.java new file mode 100644 index 000000000..0f73c4045 --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/LoanTransferabilityServiceImpl.java @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.investor.service; + +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.investor.data.ExternalTransferStatus; +import org.apache.fineract.investor.data.ExternalTransferSubStatus; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; + +@RequiredArgsConstructor +public class LoanTransferabilityServiceImpl implements LoanTransferabilityService { + + private final DelayedSettlementAttributeService delayedSettlementAttributeService; + + @Override + public boolean isTransferable(final Loan loan, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + if (shouldValidateTransferable(loan, externalAssetOwnerTransfer)) { + return MathUtil.nullToDefault(loan.getSummary().getTotalOutstanding(), BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0; + } + + return true; + } + + @Override + public ExternalTransferSubStatus getDeclinedSubStatus(final Loan loan) { + if (MathUtil.nullToDefault(loan.getTotalOverpaid(), BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0) { + return ExternalTransferSubStatus.BALANCE_NEGATIVE; + } + + return ExternalTransferSubStatus.BALANCE_ZERO; + } + + private boolean shouldValidateTransferable(final Loan loan, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { + if (!delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId())) { + // When delayed settlement is disabled, asset is directly sold to investor. Need to validate. + return true; + } + + if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) { + // When delayed settlement is enabled and asset is sold to intermediate. Need to validate. + return true; + } + + // When delayed settlement is enabled and asset is sold from intermediate to investor. No need to validate. + return false; + } +} diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializer.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializer.java index a056a30ea..fc6f09013 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializer.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializer.java @@ -20,14 +20,18 @@ package org.apache.fineract.investor.service.serialization.serializer.investor; import static org.apache.fineract.infrastructure.core.service.DateUtils.DEFAULT_DATE_FORMATTER; import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE; +import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK; +import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferStatus.CANCELLED; import static org.apache.fineract.investor.data.ExternalTransferStatus.DECLINED; +import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING_INTERMEDIATE; import java.math.BigDecimal; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.avro.generic.GenericContainer; import org.apache.fineract.avro.generator.ByteBufferSerializable; @@ -50,6 +54,9 @@ import org.springframework.stereotype.Component; @RequiredArgsConstructor public class InvestorBusinessEventSerializer implements BusinessEventSerializer { + private static final Set EXECUTED_TRANSFER_STATUSES = Set.of(ACTIVE, ACTIVE_INTERMEDIATE, BUYBACK, + BUYBACK_INTERMEDIATE); + private final ExternalAssetOwnersReadService externalAssetOwnersReadService; private static CurrencyDataV1 getCurrencyFromEvent(InvestorBusinessEvent event) { @@ -83,6 +90,7 @@ public class InvestorBusinessEventSerializer implements BusinessEventSerializer LoanOwnershipTransferDataV1.Builder builder = LoanOwnershipTransferDataV1.newBuilder().setLoanId(transferData.getLoan().getLoanId()) .setLoanExternalId(transferData.getLoan().getExternalId()).setTransferExternalId(transferData.getTransferExternalId()) .setAssetOwnerExternalId(transferData.getOwner().getExternalId()) + .setTransferExternalGroupId(transferData.getTransferExternalGroupId()) .setPurchasePriceRatio(transferData.getPurchasePriceRatio()).setCurrency(getCurrencyFromEvent(event)) .setSettlementDate(transferData.getSettlementDate().format(DEFAULT_DATE_FORMATTER)) .setSubmittedDate(transferData.getSettlementDate().format(DEFAULT_DATE_FORMATTER)).setType(transferType) @@ -103,7 +111,15 @@ public class InvestorBusinessEventSerializer implements BusinessEventSerializer @NotNull private static String getType(ExternalTransferStatus transferStatus) { - return transferStatus == BUYBACK ? "BUYBACK" : "SALE"; + if (transferStatus == BUYBACK || transferStatus == BUYBACK_INTERMEDIATE) { + return "BUYBACK"; + } + + if (transferStatus == ACTIVE_INTERMEDIATE || transferStatus == PENDING_INTERMEDIATE) { + return "INTERMEDIARYSALE"; + } + + return "SALE"; } private List getUnpaidChargeData(InvestorBusinessEvent event) { @@ -126,7 +142,7 @@ public class InvestorBusinessEventSerializer implements BusinessEventSerializer } private String getStatus(ExternalTransferStatus status) { - if (ACTIVE.equals(status) || BUYBACK.equals(status)) { + if (EXECUTED_TRANSFER_STATUSES.contains(status)) { return "EXECUTED"; } else if (DECLINED.equals(status) || CANCELLED.equals(status)) { return status.name(); diff --git a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml index e2d1717dc..6023efbdf 100644 --- a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml +++ b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml @@ -36,4 +36,6 @@ + + diff --git a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0015_add_intermediary_sale_command.xml b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0015_add_intermediary_sale_command.xml new file mode 100644 index 000000000..acf74a75e --- /dev/null +++ b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0015_add_intermediary_sale_command.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0016_add_external_reference_id.xml b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0016_add_external_reference_id.xml new file mode 100644 index 000000000..b47989f7d --- /dev/null +++ b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0016_add_external_reference_id.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java index 81500a08f..ed5fa3934 100644 --- a/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java @@ -24,18 +24,20 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -import java.math.BigDecimal; import java.time.LocalDate; import java.time.ZoneId; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; import org.apache.fineract.infrastructure.core.domain.ActionContext; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; @@ -45,19 +47,26 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccount import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.investor.data.ExternalTransferStatus; import org.apache.fineract.investor.data.ExternalTransferSubStatus; +import org.apache.fineract.investor.domain.ExternalAssetOwner; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMapping; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMappingRepository; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository; import org.apache.fineract.investor.domain.LoanOwnershipTransferBusinessEvent; import org.apache.fineract.investor.service.AccountingService; +import org.apache.fineract.investor.service.DelayedSettlementAttributeService; +import org.apache.fineract.investor.service.LoanTransferabilityService; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; @@ -69,17 +78,27 @@ import org.springframework.data.jpa.domain.Specification; public class LoanAccountOwnerTransferBusinessStepTest { public static final LocalDate FUTURE_DATE_9999_12_31 = LocalDate.of(9999, 12, 31); + private static final Long LOAN_PRODUCT_ID = 2L; private final LocalDate actualDate = LocalDate.now(ZoneId.systemDefault()); + @Mock private ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository; + @Mock private ExternalAssetOwnerTransferLoanMappingRepository externalAssetOwnerTransferLoanMappingRepository; + @Mock + private AccountingService accountingService; + @Mock private BusinessEventNotifierService businessEventNotifierService; @Mock - private AccountingService accountingService; + private LoanTransferabilityService loanTransferabilityService; + + @Mock + private DelayedSettlementAttributeService delayedSettlementAttributeService; + private LoanAccountOwnerTransferBusinessStep underTest; @BeforeEach @@ -88,7 +107,8 @@ public class LoanAccountOwnerTransferBusinessStepTest { ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, actualDate))); underTest = new LoanAccountOwnerTransferBusinessStep(externalAssetOwnerTransferRepository, - externalAssetOwnerTransferLoanMappingRepository, accountingService, businessEventNotifierService); + externalAssetOwnerTransferLoanMappingRepository, accountingService, businessEventNotifierService, + loanTransferabilityService, delayedSettlementAttributeService); } @AfterEach @@ -106,15 +126,21 @@ public class LoanAccountOwnerTransferBusinessStepTest { final Loan processedLoan = underTest.execute(loanForProcessing); // then verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); - verifyNoInteractions(businessEventNotifierService); + verifyNoInteractions(businessEventNotifierService, loanTransferabilityService, accountingService); assertEquals(processedLoan, loanForProcessing); } @Test public void givenLoanTwoTransferButInvalidTransfers() { // given + final LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); + when(delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(false); + ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); ExternalAssetOwnerTransfer secondResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); when(firstResponseItem.getStatus()).thenReturn(ExternalTransferStatus.PENDING); @@ -127,14 +153,46 @@ public class LoanAccountOwnerTransferBusinessStepTest { // then assertEquals("Illegal transfer found. Expected PENDING and BUYBACK, found: PENDING and ACTIVE", exception.getMessage()); verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); - verifyNoInteractions(businessEventNotifierService); + verifyNoInteractions(businessEventNotifierService, loanTransferabilityService, accountingService); + } + + @Test + public void givenSameDaySaleAndBuybackWithDelayedSettlement() { + // given + final LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + + final Loan loanForProcessing = Mockito.mock(Loan.class); + when(loanForProcessing.getId()).thenReturn(1L); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); + when(delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(true); + + ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer secondResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); + when(firstResponseItem.getStatus()).thenReturn(ExternalTransferStatus.PENDING); + when(secondResponseItem.getStatus()).thenReturn(ExternalTransferStatus.BUYBACK); + List response = List.of(firstResponseItem, secondResponseItem); + when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) + .thenReturn(response); + // when + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> underTest.execute(loanForProcessing)); + // then + assertEquals("Delayed Settlement enabled, but found 2 transfers of statuses: PENDING and BUYBACK", exception.getMessage()); + verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); + verifyNoInteractions(businessEventNotifierService, loanTransferabilityService, accountingService); } @Test public void givenLoanTwoTransferSameDay() { // given + final LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); + when(delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(false); + ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); ExternalAssetOwnerTransfer secondResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); @@ -192,13 +250,20 @@ public class LoanAccountOwnerTransferBusinessStepTest { assertEquals(processedLoan, loanForProcessing); + verifyNoInteractions(loanTransferabilityService, accountingService); + ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(2); verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, secondSaveResult); verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing, fourthSaveResult); } - @Test - public void givenLoanBuyback() { + private static Stream buybackStatusDataProvider() { + return Stream.of(Arguments.of(ExternalTransferStatus.BUYBACK_INTERMEDIATE), Arguments.of(ExternalTransferStatus.BUYBACK)); + } + + @ParameterizedTest + @MethodSource("buybackStatusDataProvider") + public void givenLoanBuyback(final ExternalTransferStatus buybackStatus) { // given final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); @@ -206,7 +271,7 @@ public class LoanAccountOwnerTransferBusinessStepTest { when(loanForProcessing.getSummary()).thenReturn(loanSummary); ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); ExternalAssetOwnerTransfer secondResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(firstResponseItem.getStatus()).thenReturn(ExternalTransferStatus.BUYBACK); + when(firstResponseItem.getStatus()).thenReturn(buybackStatus); List response = List.of(firstResponseItem); when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) .thenReturn(response); @@ -218,6 +283,8 @@ public class LoanAccountOwnerTransferBusinessStepTest { // when final Loan processedLoan = underTest.execute(loanForProcessing); // then + verifyNoInteractions(loanTransferabilityService); + verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); verify(firstResponseItem).setEffectiveDateTo(actualDate); verify(externalAssetOwnerTransferRepository, times(2)).save(externalAssetOwnerTransferArgumentCaptor.capture()); @@ -226,147 +293,248 @@ public class LoanAccountOwnerTransferBusinessStepTest { assertEquals(processedLoan, loanForProcessing); + verify(accountingService).createJournalEntriesForBuybackAssetTransfer(loanForProcessing, firstResponseItem); + verifyNoMoreInteractions(accountingService); + ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(2); verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, firstResponseItem); verifyLoanAccountSnapshotBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing); } - @Test - public void givenLoanSale() { + private static Stream loanSaleTransferableDataProvider() { + return Stream.of(Arguments.of(false, ExternalTransferStatus.PENDING, ExternalTransferStatus.ACTIVE), + Arguments.of(true, ExternalTransferStatus.PENDING_INTERMEDIATE, ExternalTransferStatus.ACTIVE_INTERMEDIATE)); + } + + @ParameterizedTest + @MethodSource("loanSaleTransferableDataProvider") + public void givenLoanSaleTransferable(final boolean isDelayedSettlementEnabled, final ExternalTransferStatus pendingStatus, + final ExternalTransferStatus expectedActiveStatus) { // given + final LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); - ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(firstResponseItem.getStatus()).thenReturn(ExternalTransferStatus.PENDING); - List response = List.of(firstResponseItem); - when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) - .thenReturn(response); - ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor - .forClass(ExternalAssetOwnerTransfer.class); - ArgumentCaptor externalAssetOwnerTransferLoanMappingArgumentCaptor = ArgumentCaptor - .forClass(ExternalAssetOwnerTransferLoanMapping.class); - ExternalAssetOwnerTransfer newTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(externalAssetOwnerTransferRepository.save(any())).thenReturn(firstResponseItem).thenReturn(newTransfer); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); + when(delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(isDelayedSettlementEnabled); + LoanSummary loanSummary = Mockito.mock(LoanSummary.class); when(loanForProcessing.getSummary()).thenReturn(loanSummary); - when(loanSummary.getTotalOutstanding()).thenReturn(BigDecimal.ONE); - when(newTransfer.getStatus()).thenReturn(ExternalTransferStatus.ACTIVE); + + ExternalAssetOwnerTransfer pendingTransfer = new ExternalAssetOwnerTransfer(); + pendingTransfer.setStatus(pendingStatus); + List response = List.of(pendingTransfer); + when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) + .thenReturn(response); + + when(loanTransferabilityService.isTransferable(loanForProcessing, pendingTransfer)).thenReturn(true); + + ExternalAssetOwnerTransfer savedNewTransfer = new ExternalAssetOwnerTransfer(); + savedNewTransfer.setStatus(expectedActiveStatus); + when(externalAssetOwnerTransferRepository.save(any())).thenReturn(pendingTransfer).thenReturn(savedNewTransfer); + // when final Loan processedLoan = underTest.execute(loanForProcessing); // then - verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); - verify(firstResponseItem).setEffectiveDateTo(actualDate); - verify(externalAssetOwnerTransferRepository, times(2)).save(externalAssetOwnerTransferArgumentCaptor.capture()); + verify(loanTransferabilityService).isTransferable(loanForProcessing, pendingTransfer); + verifyNoMoreInteractions(loanTransferabilityService); + verify(externalAssetOwnerTransferRepository).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getOwner(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getOwner()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getExternalId(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getExternalId()); - assertEquals(ExternalTransferStatus.ACTIVE, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getStatus()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getSettlementDate()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getLoanId(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getLoanId()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getPurchasePriceRatio(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getPurchasePriceRatio()); - assertEquals(actualDate.plusDays(1), externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getEffectiveDateFrom()); - assertEquals(FUTURE_DATE_9999_12_31, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getEffectiveDateTo()); + ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + verify(externalAssetOwnerTransferRepository, times(2)).save(externalAssetOwnerTransferArgumentCaptor.capture()); + ExternalAssetOwnerTransfer capturedPendingTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0); + ExternalAssetOwnerTransfer capturedActiveTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1); + + assertEquals(actualDate, capturedPendingTransfer.getEffectiveDateTo()); + + assertCommonFieldsOfPendingAndActiveTransfers(capturedPendingTransfer, capturedActiveTransfer); + assertEquals(expectedActiveStatus, capturedActiveTransfer.getStatus()); + assertEquals(actualDate, capturedActiveTransfer.getSettlementDate()); + assertEquals(actualDate.plusDays(1), capturedActiveTransfer.getEffectiveDateFrom()); + assertEquals(FUTURE_DATE_9999_12_31, capturedActiveTransfer.getEffectiveDateTo()); + + ArgumentCaptor externalAssetOwnerTransferLoanMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferLoanMapping.class); + verify(externalAssetOwnerTransferLoanMappingRepository).save(externalAssetOwnerTransferLoanMappingArgumentCaptor.capture()); + assertEquals(1L, externalAssetOwnerTransferLoanMappingArgumentCaptor.getValue().getLoanId()); + assertEquals(savedNewTransfer, externalAssetOwnerTransferLoanMappingArgumentCaptor.getValue().getOwnerTransfer()); + assertEquals(processedLoan, loanForProcessing); + + verify(externalAssetOwnerTransferLoanMappingRepository).save(externalAssetOwnerTransferLoanMappingArgumentCaptor.capture()); + + verify(accountingService).createJournalEntriesForSaleAssetTransfer(loanForProcessing, savedNewTransfer, null); + verifyNoMoreInteractions(accountingService); + + ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(2); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, savedNewTransfer); + verifyLoanAccountSnapshotBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing); + } + + private static Stream loanSaleNotTransferableDataProvider() { + return Stream.of(Arguments.of(ExternalTransferStatus.PENDING, ExternalTransferSubStatus.BALANCE_ZERO), + Arguments.of(ExternalTransferStatus.PENDING, ExternalTransferSubStatus.BALANCE_NEGATIVE), + Arguments.of(ExternalTransferStatus.PENDING_INTERMEDIATE, ExternalTransferSubStatus.BALANCE_ZERO), + Arguments.of(ExternalTransferStatus.PENDING_INTERMEDIATE, ExternalTransferSubStatus.BALANCE_NEGATIVE)); + } + + @ParameterizedTest + @MethodSource("loanSaleNotTransferableDataProvider") + public void givenLoanSaleNotTransferable(final ExternalTransferStatus pendingStatus, + final ExternalTransferSubStatus expectedSubStatus) { + // given + final Loan loanForProcessing = Mockito.mock(Loan.class); + when(loanForProcessing.getId()).thenReturn(1L); + + ExternalAssetOwnerTransfer pendingTransfer = new ExternalAssetOwnerTransfer(); + pendingTransfer.setStatus(pendingStatus); + List response = List.of(pendingTransfer); + when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) + .thenReturn(response); + + when(loanTransferabilityService.isTransferable(loanForProcessing, pendingTransfer)).thenReturn(false); + when(loanTransferabilityService.getDeclinedSubStatus(loanForProcessing)).thenReturn(expectedSubStatus); + + ExternalAssetOwnerTransfer savedNewTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + when(savedNewTransfer.getStatus()).thenReturn(ExternalTransferStatus.DECLINED); + when(externalAssetOwnerTransferRepository.save(any())).thenReturn(pendingTransfer).thenReturn(savedNewTransfer); + + // when + final Loan processedLoan = underTest.execute(loanForProcessing); + // then + verify(loanTransferabilityService).isTransferable(loanForProcessing, pendingTransfer); + verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); + + ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + verify(externalAssetOwnerTransferRepository, times(2)).save(externalAssetOwnerTransferArgumentCaptor.capture()); + ExternalAssetOwnerTransfer capturedPendingTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0); + ExternalAssetOwnerTransfer capturedActiveTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1); + + assertEquals(actualDate, capturedPendingTransfer.getEffectiveDateTo()); + + assertCommonFieldsOfPendingAndActiveTransfers(capturedPendingTransfer, capturedActiveTransfer); + assertEquals(ExternalTransferStatus.DECLINED, capturedActiveTransfer.getStatus()); + assertEquals(expectedSubStatus, capturedActiveTransfer.getSubStatus()); + assertEquals(actualDate, capturedActiveTransfer.getSettlementDate()); + assertEquals(actualDate, capturedActiveTransfer.getEffectiveDateFrom()); + assertEquals(actualDate, capturedActiveTransfer.getEffectiveDateTo()); + assertEquals(processedLoan, loanForProcessing); + + verifyNoInteractions(accountingService); + + ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(1); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, savedNewTransfer); + } + + @Test + public void testSaleLoanWithDelayedSettlementFromIntermediateToInvestor() { + // given + final LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + + final Loan loanForProcessing = Mockito.mock(Loan.class); + when(loanForProcessing.getId()).thenReturn(1L); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); + when(delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(true); + + LoanSummary loanSummary = Mockito.mock(LoanSummary.class); + when(loanForProcessing.getSummary()).thenReturn(loanSummary); + + ExternalAssetOwner previousOwner = new ExternalAssetOwner(); + ExternalAssetOwnerTransfer activeIntermediateTransfer = new ExternalAssetOwnerTransfer(); + activeIntermediateTransfer.setOwner(previousOwner); + activeIntermediateTransfer.setStatus(ExternalTransferStatus.ACTIVE_INTERMEDIATE); + when(externalAssetOwnerTransferRepository.findOne(any(Specification.class))).thenReturn(Optional.of(activeIntermediateTransfer)); + + ExternalAssetOwnerTransfer pendingTransfer = new ExternalAssetOwnerTransfer(); + pendingTransfer.setStatus(ExternalTransferStatus.PENDING); + List response = List.of(pendingTransfer); + when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) + .thenReturn(response); + + when(loanTransferabilityService.isTransferable(loanForProcessing, pendingTransfer)).thenReturn(true); + + ExternalAssetOwnerTransfer savedNewTransfer = new ExternalAssetOwnerTransfer(); + savedNewTransfer.setStatus(ExternalTransferStatus.ACTIVE); + when(externalAssetOwnerTransferRepository.save(any())).thenReturn(pendingTransfer).thenReturn(savedNewTransfer); + + // when + final Loan processedLoan = underTest.execute(loanForProcessing); + + // then + verify(loanTransferabilityService).isTransferable(loanForProcessing, pendingTransfer); + verifyNoMoreInteractions(loanTransferabilityService); + verify(externalAssetOwnerTransferRepository).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); + verify(externalAssetOwnerTransferRepository).findOne(any(Specification.class)); + + ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + verify(externalAssetOwnerTransferRepository, times(3)).save(externalAssetOwnerTransferArgumentCaptor.capture()); + ExternalAssetOwnerTransfer capturedActiveIntermediateTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0); + ExternalAssetOwnerTransfer capturedPendingTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1); + ExternalAssetOwnerTransfer capturedActiveTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(2); + + assertEquals(actualDate, capturedActiveIntermediateTransfer.getEffectiveDateTo()); + assertEquals(actualDate, capturedPendingTransfer.getEffectiveDateTo()); + + assertCommonFieldsOfPendingAndActiveTransfers(capturedPendingTransfer, capturedActiveTransfer); + assertEquals(ExternalTransferStatus.ACTIVE, capturedActiveTransfer.getStatus()); + assertEquals(actualDate, capturedActiveTransfer.getSettlementDate()); + assertEquals(actualDate.plusDays(1), capturedActiveTransfer.getEffectiveDateFrom()); + assertEquals(FUTURE_DATE_9999_12_31, capturedActiveTransfer.getEffectiveDateTo()); + + ArgumentCaptor externalAssetOwnerTransferLoanMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferLoanMapping.class); verify(externalAssetOwnerTransferLoanMappingRepository, times(1)) .save(externalAssetOwnerTransferLoanMappingArgumentCaptor.capture()); assertEquals(1L, externalAssetOwnerTransferLoanMappingArgumentCaptor.getValue().getLoanId()); - assertEquals(newTransfer, externalAssetOwnerTransferLoanMappingArgumentCaptor.getValue().getOwnerTransfer()); + assertEquals(savedNewTransfer, externalAssetOwnerTransferLoanMappingArgumentCaptor.getValue().getOwnerTransfer()); assertEquals(processedLoan, loanForProcessing); + verify(accountingService).createJournalEntriesForSaleAssetTransfer(loanForProcessing, savedNewTransfer, previousOwner); + verifyNoMoreInteractions(accountingService); + ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(2); - verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, newTransfer); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, savedNewTransfer); verifyLoanAccountSnapshotBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing); } @Test - public void givenLoanSaleButBalanceIsZero() { + public void testSaleLoanWithDelayedSettlementFromIntermediateToInvestorActiveIntermediateTransferNotFound() { // given + final LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); - ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(firstResponseItem.getStatus()).thenReturn(ExternalTransferStatus.PENDING); - List response = List.of(firstResponseItem); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); + when(delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(true); + + when(externalAssetOwnerTransferRepository.findOne(any(Specification.class))).thenReturn(Optional.empty()); + + ExternalAssetOwnerTransfer pendingTransfer = new ExternalAssetOwnerTransfer(); + pendingTransfer.setStatus(ExternalTransferStatus.PENDING); + List response = List.of(pendingTransfer); when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) .thenReturn(response); - ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor - .forClass(ExternalAssetOwnerTransfer.class); - ExternalAssetOwnerTransfer newTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(externalAssetOwnerTransferRepository.save(any())).thenReturn(firstResponseItem).thenReturn(newTransfer); - LoanSummary loanSummary = Mockito.mock(LoanSummary.class); - when(loanForProcessing.getSummary()).thenReturn(loanSummary); - when(loanSummary.getTotalOutstanding()).thenReturn(BigDecimal.ZERO); - when(loanForProcessing.getTotalOverpaid()).thenReturn(BigDecimal.ZERO); - when(newTransfer.getStatus()).thenReturn(ExternalTransferStatus.DECLINED); + when(loanTransferabilityService.isTransferable(loanForProcessing, pendingTransfer)).thenReturn(true); + // when - final Loan processedLoan = underTest.execute(loanForProcessing); + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> underTest.execute(loanForProcessing)); + // then - verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); - verify(firstResponseItem).setEffectiveDateTo(actualDate); - verify(externalAssetOwnerTransferRepository, times(2)).save(externalAssetOwnerTransferArgumentCaptor.capture()); + assertEquals("Expected a effective transfer of ACTIVE_INTERMEDIATE status to be present.", exception.getMessage()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getOwner(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getOwner()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getExternalId(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getExternalId()); - assertEquals(ExternalTransferStatus.DECLINED, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getStatus()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getSettlementDate()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getLoanId(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getLoanId()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getPurchasePriceRatio(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getPurchasePriceRatio()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getEffectiveDateFrom()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getEffectiveDateTo()); - assertEquals(processedLoan, loanForProcessing); - - ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(1); - verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, newTransfer); - } - - @Test - public void givenLoanSaleButBalanceIsNegative() { - // given - final Loan loanForProcessing = Mockito.mock(Loan.class); - when(loanForProcessing.getId()).thenReturn(1L); - ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(firstResponseItem.getStatus()).thenReturn(ExternalTransferStatus.PENDING); - List response = List.of(firstResponseItem); - when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) - .thenReturn(response); - ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor - .forClass(ExternalAssetOwnerTransfer.class); - ExternalAssetOwnerTransfer newTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(externalAssetOwnerTransferRepository.save(any())).thenReturn(firstResponseItem).thenReturn(newTransfer); - LoanSummary loanSummary = Mockito.mock(LoanSummary.class); - when(loanForProcessing.getSummary()).thenReturn(loanSummary); - when(loanSummary.getTotalOutstanding()).thenReturn(BigDecimal.ONE.negate()); - when(loanForProcessing.getTotalOverpaid()).thenReturn(BigDecimal.ONE.negate()); - when(newTransfer.getStatus()).thenReturn(ExternalTransferStatus.DECLINED); - // when - final Loan processedLoan = underTest.execute(loanForProcessing); - // then - verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); - verify(firstResponseItem).setEffectiveDateTo(actualDate); - verify(externalAssetOwnerTransferRepository, times(2)).save(externalAssetOwnerTransferArgumentCaptor.capture()); - - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getOwner(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getOwner()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getExternalId(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getExternalId()); - assertEquals(ExternalTransferStatus.DECLINED, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getStatus()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getSettlementDate()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getLoanId(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getLoanId()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getPurchasePriceRatio(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getPurchasePriceRatio()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getEffectiveDateFrom()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getEffectiveDateTo()); - assertEquals(processedLoan, loanForProcessing); - - ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(1); - verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, newTransfer); + verify(loanTransferabilityService).isTransferable(loanForProcessing, pendingTransfer); + verifyNoMoreInteractions(loanTransferabilityService); + verifyNoInteractions(accountingService); + verify(externalAssetOwnerTransferRepository).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); + verify(externalAssetOwnerTransferRepository).findOne(any(Specification.class)); + verify(externalAssetOwnerTransferRepository, never()).save(any(ExternalAssetOwnerTransfer.class)); + verifyNoInteractions(externalAssetOwnerTransferLoanMappingRepository); + verifyBusinessEvents(0); } @Test @@ -383,55 +551,6 @@ public class LoanAccountOwnerTransferBusinessStepTest { assertEquals("Execute external asset owner transfer", actualEnumName); } - @Test - public void givenLoanSaleAnsBuyBackButBalanceIsNegative() { - // given - final Loan loanForProcessing = Mockito.mock(Loan.class); - when(loanForProcessing.getId()).thenReturn(1L); - LoanSummary loanSummary = Mockito.mock(LoanSummary.class); - when(loanForProcessing.getSummary()).thenReturn(loanSummary); - when(loanSummary.getTotalOutstanding()).thenReturn(BigDecimal.ZERO); - when(loanForProcessing.getTotalOverpaid()).thenReturn(BigDecimal.ONE); - ExternalAssetOwnerTransfer firstResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); - ExternalAssetOwnerTransfer secondResponseItem = Mockito.mock(ExternalAssetOwnerTransfer.class); - secondResponseItem.setSettlementDate(actualDate.plusDays(2)); - - ExternalAssetOwnerTransfer firstSaveResult = Mockito.mock(ExternalAssetOwnerTransfer.class); - ExternalAssetOwnerTransfer secondSaveResult = Mockito.mock(ExternalAssetOwnerTransfer.class); - secondSaveResult.setSettlementDate(actualDate.plusDays(2)); - ExternalAssetOwnerTransfer thirdSaveResult = Mockito.mock(ExternalAssetOwnerTransfer.class); - ExternalAssetOwnerTransfer fourthSaveResult = Mockito.mock(ExternalAssetOwnerTransfer.class); - - when(firstResponseItem.getStatus()).thenReturn(ExternalTransferStatus.PENDING); - when(externalAssetOwnerTransferRepository.save(any(ExternalAssetOwnerTransfer.class))).thenReturn(firstSaveResult) - .thenReturn(secondSaveResult).thenReturn(thirdSaveResult).thenReturn(fourthSaveResult); - List response = List.of(firstResponseItem); - when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id")))) - .thenReturn(response); - ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor - .forClass(ExternalAssetOwnerTransfer.class); - // when - Loan processedLoan = underTest.execute(loanForProcessing); - // then - verify(externalAssetOwnerTransferRepository, times(1)).findAll(any(Specification.class), eq(Sort.by(Sort.Direction.ASC, "id"))); - verify(firstResponseItem).setEffectiveDateTo(actualDate); - verify(externalAssetOwnerTransferRepository, times(2)).save(externalAssetOwnerTransferArgumentCaptor.capture()); - - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getOwner(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getOwner()); - assertEquals(externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0).getExternalId(), - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getExternalId()); - assertEquals(ExternalTransferStatus.DECLINED, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getStatus()); - assertEquals(ExternalTransferSubStatus.BALANCE_NEGATIVE, - externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getSubStatus()); - assertEquals(actualDate, externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1).getSettlementDate()); - - assertEquals(processedLoan, loanForProcessing); - - ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(2); - verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, secondSaveResult); - } - @NotNull private ArgumentCaptor> verifyBusinessEvents(int expectedBusinessEvents) { @SuppressWarnings("unchecked") @@ -453,4 +572,12 @@ public class LoanAccountOwnerTransferBusinessStepTest { assertTrue(businessEventArgumentCaptor.getAllValues().get(index) instanceof LoanAccountSnapshotBusinessEvent); assertEquals(expectedLoan, ((LoanAccountSnapshotBusinessEvent) businessEventArgumentCaptor.getAllValues().get(index)).get()); } + + private void assertCommonFieldsOfPendingAndActiveTransfers(final ExternalAssetOwnerTransfer pendingTransfer, + final ExternalAssetOwnerTransfer activeTransfer) { + assertEquals(pendingTransfer.getOwner(), activeTransfer.getOwner()); + assertEquals(pendingTransfer.getExternalId(), activeTransfer.getExternalId()); + assertEquals(pendingTransfer.getLoanId(), activeTransfer.getLoanId()); + assertEquals(pendingTransfer.getPurchasePriceRatio(), activeTransfer.getPurchasePriceRatio()); + } } diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/AccountingServiceImplTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/AccountingServiceImplTest.java new file mode 100644 index 000000000..9e20c1e4e --- /dev/null +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/AccountingServiceImplTest.java @@ -0,0 +1,594 @@ +/** + * 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.investor.service; + +import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.accounting.common.AccountingConstants; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.investor.accounting.journalentry.service.InvestorAccountingHelper; +import org.apache.fineract.investor.domain.ExternalAssetOwner; +import org.apache.fineract.investor.domain.ExternalAssetOwnerJournalEntryMapping; +import org.apache.fineract.investor.domain.ExternalAssetOwnerJournalEntryMappingRepository; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferJournalEntryMapping; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferJournalEntryMappingRepository; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AccountingServiceImplTest { + + @BeforeEach + public void setUp() { + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BUSINESS_DATE, LocalDate.of(2024, 9, 27)))); + } + + @Test + void createJournalEntriesForSaleAssetTransfer() { + // given + TestContext testContext = new TestContext(); + Loan loan = testContext.createMockedLoan(); + + ExternalAssetOwner previousOwner = new ExternalAssetOwner(); + ExternalAssetOwner newOwner = new ExternalAssetOwner(); + ExternalAssetOwnerTransfer transfer = new ExternalAssetOwnerTransfer(); + transfer.setOwner(newOwner); + + JournalEntry principleAndInterestDebitJournalEntry = createJournalEntry(11, testContext.principleAndInterestAccount, + JournalEntryType.DEBIT); + JournalEntry principleAndInterestCreditJournalEntry = createJournalEntry(12, testContext.principleAndInterestAccount, + JournalEntryType.CREDIT); + JournalEntry feeAndPenaltyDebitJournalEntry = createJournalEntry(13, testContext.feeAndPenaltyAccount, JournalEntryType.DEBIT); + JournalEntry feeAndPenaltyCreditJournalEntry = createJournalEntry(14, testContext.feeAndPenaltyAccount, JournalEntryType.CREDIT); + JournalEntry transferDebitJournalEntry = createJournalEntry(15, testContext.transferAccount, JournalEntryType.DEBIT); + JournalEntry transferCreditJournalEntry = createJournalEntry(16, testContext.transferAccount, JournalEntryType.CREDIT); + + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.principleAndInterestAccount))).thenReturn(principleAndInterestCreditJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.feeAndPenaltyAccount))).thenReturn(feeAndPenaltyCreditJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(false))).thenReturn(transferDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.principleAndInterestAccount))).thenReturn(principleAndInterestDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.feeAndPenaltyAccount))).thenReturn(feeAndPenaltyDebitJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(true))).thenReturn(transferCreditJournalEntry); + + // when + testContext.testSubject.createJournalEntriesForSaleAssetTransfer(loan, transfer, previousOwner); + + // then + ArgumentCaptor ownerJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerJournalEntryMapping.class); + verify(testContext.externalAssetOwnerJournalEntryMappingRepository, times(4)) + .saveAndFlush(ownerJournalEntryMappingArgumentCaptor.capture()); + List capturedOwnerJournalEntryMappings = ownerJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedOwnerJournalEntryMappings = List.of( + ownerJournalEntryMapping(principleAndInterestCreditJournalEntry, previousOwner), + ownerJournalEntryMapping(feeAndPenaltyCreditJournalEntry, previousOwner), + ownerJournalEntryMapping(principleAndInterestDebitJournalEntry, newOwner), + ownerJournalEntryMapping(feeAndPenaltyDebitJournalEntry, newOwner)); + assertNotNull(capturedOwnerJournalEntryMappings); + assertOwnerJournalEntryMappings(expectedOwnerJournalEntryMappings, capturedOwnerJournalEntryMappings); + + ArgumentCaptor transferJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferJournalEntryMapping.class); + verify(testContext.externalAssetOwnerTransferJournalEntryMappingRepository, times(6)) + .saveAndFlush(transferJournalEntryMappingArgumentCaptor.capture()); + List capturedTransferJournalEntryMappings = transferJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedTransferJournalEntryMappings = List.of( + transferJournalEntryMapping(principleAndInterestCreditJournalEntry, transfer), + transferJournalEntryMapping(feeAndPenaltyCreditJournalEntry, transfer), + transferJournalEntryMapping(transferDebitJournalEntry, transfer), + transferJournalEntryMapping(principleAndInterestDebitJournalEntry, transfer), + transferJournalEntryMapping(feeAndPenaltyDebitJournalEntry, transfer), + transferJournalEntryMapping(transferCreditJournalEntry, transfer)); + assertNotNull(capturedTransferJournalEntryMappings); + assertTransferJournalEntryMappings(expectedTransferJournalEntryMappings, capturedTransferJournalEntryMappings); + } + + @Test + void createJournalEntriesForSaleAssetTransferOfOverpaidLoan() { + // given + TestContext testContext = new TestContext(); + Loan loan = testContext.createMockedOverpaidLoan(); + + ExternalAssetOwner previousOwner = new ExternalAssetOwner(); + ExternalAssetOwner newOwner = new ExternalAssetOwner(); + ExternalAssetOwnerTransfer transfer = new ExternalAssetOwnerTransfer(); + transfer.setOwner(newOwner); + + JournalEntry overpaidDebitJournalEntry = createJournalEntry(11, testContext.overpaymentAccount, JournalEntryType.DEBIT); + JournalEntry overpaidCreditJournalEntry = createJournalEntry(12, testContext.overpaymentAccount, JournalEntryType.CREDIT); + JournalEntry transferDebitJournalEntry = createJournalEntry(15, testContext.transferAccount, JournalEntryType.DEBIT); + JournalEntry transferCreditJournalEntry = createJournalEntry(16, testContext.transferAccount, JournalEntryType.CREDIT); + + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.overpaymentAccount))).thenReturn(overpaidCreditJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(false))).thenReturn(transferDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.overpaymentAccount))).thenReturn(overpaidDebitJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(true))).thenReturn(transferCreditJournalEntry); + + // when + testContext.testSubject.createJournalEntriesForSaleAssetTransfer(loan, transfer, previousOwner); + + // then + ArgumentCaptor ownerJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerJournalEntryMapping.class); + verify(testContext.externalAssetOwnerJournalEntryMappingRepository, times(2)) + .saveAndFlush(ownerJournalEntryMappingArgumentCaptor.capture()); + List capturedOwnerJournalEntryMappings = ownerJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedOwnerJournalEntryMappings = List.of( + ownerJournalEntryMapping(overpaidCreditJournalEntry, newOwner), + ownerJournalEntryMapping(overpaidDebitJournalEntry, previousOwner)); + assertNotNull(capturedOwnerJournalEntryMappings); + assertOwnerJournalEntryMappings(expectedOwnerJournalEntryMappings, capturedOwnerJournalEntryMappings); + + ArgumentCaptor transferJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferJournalEntryMapping.class); + verify(testContext.externalAssetOwnerTransferJournalEntryMappingRepository, times(4)) + .saveAndFlush(transferJournalEntryMappingArgumentCaptor.capture()); + List capturedTransferJournalEntryMappings = transferJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedTransferJournalEntryMappings = List.of( + transferJournalEntryMapping(overpaidCreditJournalEntry, transfer), + transferJournalEntryMapping(transferDebitJournalEntry, transfer), + transferJournalEntryMapping(overpaidDebitJournalEntry, transfer), + transferJournalEntryMapping(transferCreditJournalEntry, transfer)); + assertNotNull(capturedTransferJournalEntryMappings); + assertTransferJournalEntryMappings(expectedTransferJournalEntryMappings, capturedTransferJournalEntryMappings); + } + + @Test + void createJournalEntriesForSaleAssetTransferWithNullPreviousOwner() { + // given + TestContext testContext = new TestContext(); + Loan loan = testContext.createMockedLoan(); + + ExternalAssetOwner newOwner = new ExternalAssetOwner(); + ExternalAssetOwnerTransfer transfer = new ExternalAssetOwnerTransfer(); + transfer.setOwner(newOwner); + + JournalEntry principleAndInterestDebitJournalEntry = createJournalEntry(11, testContext.principleAndInterestAccount, + JournalEntryType.DEBIT); + JournalEntry principleAndInterestCreditJournalEntry = createJournalEntry(12, testContext.principleAndInterestAccount, + JournalEntryType.CREDIT); + JournalEntry feeAndPenaltyDebitJournalEntry = createJournalEntry(13, testContext.feeAndPenaltyAccount, JournalEntryType.DEBIT); + JournalEntry feeAndPenaltyCreditJournalEntry = createJournalEntry(14, testContext.feeAndPenaltyAccount, JournalEntryType.CREDIT); + JournalEntry transferDebitJournalEntry = createJournalEntry(15, testContext.transferAccount, JournalEntryType.DEBIT); + JournalEntry transferCreditJournalEntry = createJournalEntry(16, testContext.transferAccount, JournalEntryType.CREDIT); + + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.principleAndInterestAccount))).thenReturn(principleAndInterestCreditJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.feeAndPenaltyAccount))).thenReturn(feeAndPenaltyCreditJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(false))).thenReturn(transferDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.principleAndInterestAccount))).thenReturn(principleAndInterestDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.feeAndPenaltyAccount))).thenReturn(feeAndPenaltyDebitJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(true))).thenReturn(transferCreditJournalEntry); + + // when + testContext.testSubject.createJournalEntriesForSaleAssetTransfer(loan, transfer, null); + + // then + ArgumentCaptor ownerJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerJournalEntryMapping.class); + verify(testContext.externalAssetOwnerJournalEntryMappingRepository, times(2)) + .saveAndFlush(ownerJournalEntryMappingArgumentCaptor.capture()); + List capturedOwnerJournalEntryMappings = ownerJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedOwnerJournalEntryMappings = List.of( + ownerJournalEntryMapping(principleAndInterestDebitJournalEntry, newOwner), + ownerJournalEntryMapping(feeAndPenaltyDebitJournalEntry, newOwner)); + assertNotNull(capturedOwnerJournalEntryMappings); + assertOwnerJournalEntryMappings(expectedOwnerJournalEntryMappings, capturedOwnerJournalEntryMappings); + + ArgumentCaptor transferJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferJournalEntryMapping.class); + verify(testContext.externalAssetOwnerTransferJournalEntryMappingRepository, times(6)) + .saveAndFlush(transferJournalEntryMappingArgumentCaptor.capture()); + List capturedTransferJournalEntryMappings = transferJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedTransferJournalEntryMappings = List.of( + transferJournalEntryMapping(principleAndInterestCreditJournalEntry, transfer), + transferJournalEntryMapping(feeAndPenaltyCreditJournalEntry, transfer), + transferJournalEntryMapping(transferDebitJournalEntry, transfer), + transferJournalEntryMapping(principleAndInterestDebitJournalEntry, transfer), + transferJournalEntryMapping(feeAndPenaltyDebitJournalEntry, transfer), + transferJournalEntryMapping(transferCreditJournalEntry, transfer)); + assertNotNull(capturedTransferJournalEntryMappings); + assertTransferJournalEntryMappings(expectedTransferJournalEntryMappings, capturedTransferJournalEntryMappings); + } + + @Test + void createJournalEntriesForSaleAssetTransferOfOverpaidLoanWithNullPreviousOwner() { + // given + TestContext testContext = new TestContext(); + Loan loan = testContext.createMockedOverpaidLoan(); + + ExternalAssetOwner newOwner = new ExternalAssetOwner(); + ExternalAssetOwnerTransfer transfer = new ExternalAssetOwnerTransfer(); + transfer.setOwner(newOwner); + + JournalEntry overpaidDebitJournalEntry = createJournalEntry(11, testContext.overpaymentAccount, JournalEntryType.DEBIT); + JournalEntry overpaidCreditJournalEntry = createJournalEntry(12, testContext.overpaymentAccount, JournalEntryType.CREDIT); + JournalEntry transferDebitJournalEntry = createJournalEntry(15, testContext.transferAccount, JournalEntryType.DEBIT); + JournalEntry transferCreditJournalEntry = createJournalEntry(16, testContext.transferAccount, JournalEntryType.CREDIT); + + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.overpaymentAccount))).thenReturn(overpaidCreditJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(false))).thenReturn(transferDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.overpaymentAccount))).thenReturn(overpaidDebitJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(true))).thenReturn(transferCreditJournalEntry); + + // when + testContext.testSubject.createJournalEntriesForSaleAssetTransfer(loan, transfer, null); + + // then + ArgumentCaptor ownerJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerJournalEntryMapping.class); + verify(testContext.externalAssetOwnerJournalEntryMappingRepository).saveAndFlush(ownerJournalEntryMappingArgumentCaptor.capture()); + List capturedOwnerJournalEntryMappings = ownerJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedOwnerJournalEntryMappings = List + .of(ownerJournalEntryMapping(overpaidCreditJournalEntry, newOwner)); + assertNotNull(capturedOwnerJournalEntryMappings); + assertOwnerJournalEntryMappings(expectedOwnerJournalEntryMappings, capturedOwnerJournalEntryMappings); + + ArgumentCaptor transferJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferJournalEntryMapping.class); + verify(testContext.externalAssetOwnerTransferJournalEntryMappingRepository, times(4)) + .saveAndFlush(transferJournalEntryMappingArgumentCaptor.capture()); + List capturedTransferJournalEntryMappings = transferJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedTransferJournalEntryMappings = List.of( + transferJournalEntryMapping(overpaidCreditJournalEntry, transfer), + transferJournalEntryMapping(transferDebitJournalEntry, transfer), + transferJournalEntryMapping(overpaidDebitJournalEntry, transfer), + transferJournalEntryMapping(transferCreditJournalEntry, transfer)); + assertNotNull(capturedTransferJournalEntryMappings); + assertTransferJournalEntryMappings(expectedTransferJournalEntryMappings, capturedTransferJournalEntryMappings); + } + + @Test + void createJournalEntriesForBuybackAssetTransfer() { + // given + TestContext testContext = new TestContext(); + Loan loan = testContext.createMockedLoan(); + + ExternalAssetOwner previousOwner = new ExternalAssetOwner(); + ExternalAssetOwnerTransfer transfer = new ExternalAssetOwnerTransfer(); + transfer.setOwner(previousOwner); + + JournalEntry principleAndInterestDebitJournalEntry = createJournalEntry(11, testContext.principleAndInterestAccount, + JournalEntryType.DEBIT); + JournalEntry principleAndInterestCreditJournalEntry = createJournalEntry(12, testContext.principleAndInterestAccount, + JournalEntryType.CREDIT); + JournalEntry feeAndPenaltyDebitJournalEntry = createJournalEntry(13, testContext.feeAndPenaltyAccount, JournalEntryType.DEBIT); + JournalEntry feeAndPenaltyCreditJournalEntry = createJournalEntry(14, testContext.feeAndPenaltyAccount, JournalEntryType.CREDIT); + JournalEntry transferDebitJournalEntry = createJournalEntry(15, testContext.transferAccount, JournalEntryType.DEBIT); + JournalEntry transferCreditJournalEntry = createJournalEntry(16, testContext.transferAccount, JournalEntryType.CREDIT); + + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.principleAndInterestAccount))).thenReturn(principleAndInterestCreditJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.feeAndPenaltyAccount))).thenReturn(feeAndPenaltyCreditJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(false))).thenReturn(transferDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.principleAndInterestAccount))).thenReturn(principleAndInterestDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.feeAndPenaltyAccount))).thenReturn(feeAndPenaltyDebitJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(true))).thenReturn(transferCreditJournalEntry); + + // when + testContext.testSubject.createJournalEntriesForBuybackAssetTransfer(loan, transfer); + + // then + ArgumentCaptor ownerJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerJournalEntryMapping.class); + verify(testContext.externalAssetOwnerJournalEntryMappingRepository, times(2)) + .saveAndFlush(ownerJournalEntryMappingArgumentCaptor.capture()); + List capturedOwnerJournalEntryMappings = ownerJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedOwnerJournalEntryMappings = List.of( + ownerJournalEntryMapping(principleAndInterestCreditJournalEntry, previousOwner), + ownerJournalEntryMapping(feeAndPenaltyCreditJournalEntry, previousOwner)); + assertNotNull(capturedOwnerJournalEntryMappings); + assertOwnerJournalEntryMappings(expectedOwnerJournalEntryMappings, capturedOwnerJournalEntryMappings); + + ArgumentCaptor transferJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferJournalEntryMapping.class); + verify(testContext.externalAssetOwnerTransferJournalEntryMappingRepository, times(6)) + .saveAndFlush(transferJournalEntryMappingArgumentCaptor.capture()); + List capturedTransferJournalEntryMappings = transferJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedTransferJournalEntryMappings = List.of( + transferJournalEntryMapping(principleAndInterestDebitJournalEntry, transfer), + transferJournalEntryMapping(feeAndPenaltyDebitJournalEntry, transfer), + transferJournalEntryMapping(transferCreditJournalEntry, transfer), + transferJournalEntryMapping(principleAndInterestCreditJournalEntry, transfer), + transferJournalEntryMapping(feeAndPenaltyCreditJournalEntry, transfer), + transferJournalEntryMapping(transferDebitJournalEntry, transfer)); + assertNotNull(capturedTransferJournalEntryMappings); + assertTransferJournalEntryMappings(expectedTransferJournalEntryMappings, capturedTransferJournalEntryMappings); + } + + @Test + void createJournalEntriesForBuybackAssetTransferOfOverpaidLoan() { + // given + TestContext testContext = new TestContext(); + Loan loan = testContext.createMockedOverpaidLoan(); + + ExternalAssetOwner previousOwner = new ExternalAssetOwner(); + ExternalAssetOwnerTransfer transfer = new ExternalAssetOwnerTransfer(); + transfer.setOwner(previousOwner); + + JournalEntry overpaidDebitJournalEntry = createJournalEntry(11, testContext.overpaymentAccount, JournalEntryType.DEBIT); + JournalEntry overpaidCreditJournalEntry = createJournalEntry(12, testContext.overpaymentAccount, JournalEntryType.CREDIT); + JournalEntry transferDebitJournalEntry = createJournalEntry(15, testContext.transferAccount, JournalEntryType.DEBIT); + JournalEntry transferCreditJournalEntry = createJournalEntry(16, testContext.transferAccount, JournalEntryType.CREDIT); + + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(false), eq(testContext.overpaymentAccount))).thenReturn(overpaidCreditJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(false))).thenReturn(transferDebitJournalEntry); + when(testContext.investorAccountingHelper.createCreditJournalEntryOrReversalForInvestor(any(), any(), any(), any(), any(), any(), + eq(true), eq(testContext.overpaymentAccount))).thenReturn(overpaidDebitJournalEntry); + when(testContext.investorAccountingHelper.createDebitJournalEntryOrReversalForInvestor(any(), any(), anyInt(), any(), any(), any(), + any(), any(), eq(true))).thenReturn(transferCreditJournalEntry); + + // when + testContext.testSubject.createJournalEntriesForBuybackAssetTransfer(loan, transfer); + + // then + ArgumentCaptor ownerJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerJournalEntryMapping.class); + verify(testContext.externalAssetOwnerJournalEntryMappingRepository, times(1)) + .saveAndFlush(ownerJournalEntryMappingArgumentCaptor.capture()); + List capturedOwnerJournalEntryMappings = ownerJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedOwnerJournalEntryMappings = List + .of(ownerJournalEntryMapping(overpaidDebitJournalEntry, previousOwner)); + assertNotNull(capturedOwnerJournalEntryMappings); + assertOwnerJournalEntryMappings(expectedOwnerJournalEntryMappings, capturedOwnerJournalEntryMappings); + + ArgumentCaptor transferJournalEntryMappingArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransferJournalEntryMapping.class); + verify(testContext.externalAssetOwnerTransferJournalEntryMappingRepository, times(4)) + .saveAndFlush(transferJournalEntryMappingArgumentCaptor.capture()); + List capturedTransferJournalEntryMappings = transferJournalEntryMappingArgumentCaptor + .getAllValues(); + + List expectedTransferJournalEntryMappings = List.of( + transferJournalEntryMapping(overpaidDebitJournalEntry, transfer), + transferJournalEntryMapping(transferCreditJournalEntry, transfer), + transferJournalEntryMapping(overpaidCreditJournalEntry, transfer), + transferJournalEntryMapping(transferDebitJournalEntry, transfer)); + assertNotNull(capturedTransferJournalEntryMappings); + assertTransferJournalEntryMappings(expectedTransferJournalEntryMappings, capturedTransferJournalEntryMappings); + } + + private JournalEntry createJournalEntry(long id, GLAccount glAccount, JournalEntryType journalEntryType) { + JournalEntry journalEntry = JournalEntry.createNew(null, null, glAccount, null, null, false, null, journalEntryType, null, null, + null, null, null, null, null, null, null); + journalEntry.setId(id); + return journalEntry; + } + + private ExternalAssetOwnerJournalEntryMapping ownerJournalEntryMapping(JournalEntry journalEntry, ExternalAssetOwner owner) { + ExternalAssetOwnerJournalEntryMapping ownerJournalEntryMapping = new ExternalAssetOwnerJournalEntryMapping(); + ownerJournalEntryMapping.setJournalEntry(journalEntry); + ownerJournalEntryMapping.setOwner(owner); + return ownerJournalEntryMapping; + } + + private void assertOwnerJournalEntryMappings(List expectedOwnerJournalEntryMappings, + List actualOwnerJournalEntryMappings) { + assertEquals(expectedOwnerJournalEntryMappings.size(), actualOwnerJournalEntryMappings.size()); + for (int i = 0; i < expectedOwnerJournalEntryMappings.size(); i++) { + assertOwnerJournalEntryMapping(expectedOwnerJournalEntryMappings.get(i), actualOwnerJournalEntryMappings.get(i)); + } + } + + private void assertOwnerJournalEntryMapping(ExternalAssetOwnerJournalEntryMapping expectedOwnerJournalEntryMapping, + ExternalAssetOwnerJournalEntryMapping actualOwnerJournalEntryMapping) { + assertNotNull(actualOwnerJournalEntryMapping); + assertSame(expectedOwnerJournalEntryMapping.getJournalEntry(), actualOwnerJournalEntryMapping.getJournalEntry()); + assertSame(expectedOwnerJournalEntryMapping.getOwner(), actualOwnerJournalEntryMapping.getOwner()); + } + + private ExternalAssetOwnerTransferJournalEntryMapping transferJournalEntryMapping(JournalEntry journalEntry, + ExternalAssetOwnerTransfer transfer) { + ExternalAssetOwnerTransferJournalEntryMapping transferJournalEntryMapping = new ExternalAssetOwnerTransferJournalEntryMapping(); + transferJournalEntryMapping.setJournalEntry(journalEntry); + transferJournalEntryMapping.setOwnerTransfer(transfer); + return transferJournalEntryMapping; + } + + private void assertTransferJournalEntryMappings( + List expectedTransferJournalEntryMappings, + List actualTransferJournalEntryMappings) { + assertEquals(expectedTransferJournalEntryMappings.size(), actualTransferJournalEntryMappings.size()); + for (int i = 0; i < expectedTransferJournalEntryMappings.size(); i++) { + assertTransferJournalEntryMapping(expectedTransferJournalEntryMappings.get(i), actualTransferJournalEntryMappings.get(i)); + } + } + + private void assertTransferJournalEntryMapping(ExternalAssetOwnerTransferJournalEntryMapping expectedTransferJournalEntryMapping, + ExternalAssetOwnerTransferJournalEntryMapping actualTransferJournalEntryMapping) { + assertNotNull(actualTransferJournalEntryMapping); + assertSame(expectedTransferJournalEntryMapping.getJournalEntry(), actualTransferJournalEntryMapping.getJournalEntry()); + assertSame(expectedTransferJournalEntryMapping.getOwnerTransfer(), actualTransferJournalEntryMapping.getOwnerTransfer()); + } + + private static class TestContext { + + private static final Long LOAN_ID = 3001L; + private static final Long LOAN_PRODUCT_ID = 3002L; + private static final Long TRANSFER_ID = 3003L; + + @Mock + private InvestorAccountingHelper investorAccountingHelper; + + @Mock + private ExternalAssetOwnerTransferJournalEntryMappingRepository externalAssetOwnerTransferJournalEntryMappingRepository; + + @Mock + private ExternalAssetOwnerJournalEntryMappingRepository externalAssetOwnerJournalEntryMappingRepository; + + @Mock + private FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; + + @InjectMocks + private AccountingServiceImpl testSubject; + + private final GLAccount principleAndInterestAccount = new GLAccount(); + private final GLAccount feeAndPenaltyAccount = new GLAccount(); + private final GLAccount transferAccount = new GLAccount(); + private final GLAccount overpaymentAccount = new GLAccount(); + + TestContext() { + MockitoAnnotations.openMocks(this); + setupAccounts(); + } + + public Loan createMockedLoan() { + Loan loan = mock(Loan.class); + when(loan.getId()).thenReturn(LOAN_ID); + when(loan.productId()).thenReturn(LOAN_PRODUCT_ID); + + Office office = Office.headOffice("office", LocalDate.of(2024, 9, 27), new ExternalId("officeId")); + when(loan.getOffice()).thenReturn(office); + + LoanSummary loanSummary = Mockito.mock(LoanSummary.class); + when(loanSummary.getTotalPrincipalOutstanding()).thenReturn(BigDecimal.ONE); + when(loanSummary.getTotalInterestOutstanding()).thenReturn(BigDecimal.ONE); + when(loanSummary.getTotalFeeChargesOutstanding()).thenReturn(BigDecimal.ONE); + when(loanSummary.getTotalPenaltyChargesOutstanding()).thenReturn(BigDecimal.ONE); + when(loan.getSummary()).thenReturn(loanSummary); + + return loan; + } + + public Loan createMockedOverpaidLoan() { + Loan loan = mock(Loan.class); + when(loan.getId()).thenReturn(LOAN_ID); + when(loan.productId()).thenReturn(LOAN_PRODUCT_ID); + when(loan.getStatus()).thenReturn(LoanStatus.OVERPAID); + + Office office = Office.headOffice("office", LocalDate.of(2024, 9, 27), new ExternalId("officeId")); + when(loan.getOffice()).thenReturn(office); + + LoanSummary loanSummary = Mockito.mock(LoanSummary.class); + when(loan.getSummary()).thenReturn(loanSummary); + + when(loan.getTotalOverpaid()).thenReturn(BigDecimal.ONE); + + return loan; + } + + private void setupAccounts() { + principleAndInterestAccount.setId(1L); + feeAndPenaltyAccount.setId(2L); + transferAccount.setId(3L); + overpaymentAccount.setId(4L); + + FinancialActivityAccount financialActivityAccount = new FinancialActivityAccount(transferAccount, + AccountingConstants.FinancialActivity.ASSET_TRANSFER.getValue()); + when(financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(AccountingConstants.FinancialActivity.ASSET_TRANSFER.getValue())) + .thenReturn(financialActivityAccount); + + lenient().when(investorAccountingHelper.getLinkedGLAccountForLoanProduct(LOAN_PRODUCT_ID, + AccountingConstants.AccrualAccountsForLoan.LOAN_PORTFOLIO.getValue())).thenReturn(principleAndInterestAccount); + lenient() + .when(investorAccountingHelper.getLinkedGLAccountForLoanProduct(LOAN_PRODUCT_ID, + AccountingConstants.AccrualAccountsForLoan.INTEREST_RECEIVABLE.getValue())) + .thenReturn(principleAndInterestAccount); + lenient().when(investorAccountingHelper.getLinkedGLAccountForLoanProduct(LOAN_PRODUCT_ID, + AccountingConstants.AccrualAccountsForLoan.FEES_RECEIVABLE.getValue())).thenReturn(feeAndPenaltyAccount); + lenient().when(investorAccountingHelper.getLinkedGLAccountForLoanProduct(LOAN_PRODUCT_ID, + AccountingConstants.AccrualAccountsForLoan.PENALTIES_RECEIVABLE.getValue())).thenReturn(feeAndPenaltyAccount); + lenient().when(investorAccountingHelper.getLinkedGLAccountForLoanProduct(LOAN_PRODUCT_ID, + AccountingConstants.AccrualAccountsForLoan.OVERPAYMENT.getValue())).thenReturn(overpaymentAccount); + } + } +} diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/DelayedSettlementAttributeServiceImplTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/DelayedSettlementAttributeServiceImplTest.java new file mode 100644 index 000000000..5f9108c8e --- /dev/null +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/DelayedSettlementAttributeServiceImplTest.java @@ -0,0 +1,80 @@ +/** + * 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.investor.service; + +import static org.apache.fineract.investor.data.attribute.SettlementModelExternalAssetOwnerLoanProductAttribute.DEFAULT_SETTLEMENT; +import static org.apache.fineract.investor.data.attribute.SettlementModelExternalAssetOwnerLoanProductAttribute.DELAYED_SETTLEMENT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.stream.Stream; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.investor.data.ExternalTransferLoanProductAttributesData; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class DelayedSettlementAttributeServiceImplTest { + + private static final Long LOAN_PRODUCT_ID = 1L; + + private static Stream attributesDataProvider() { + ExternalTransferLoanProductAttributesData enabledAttributesData = new ExternalTransferLoanProductAttributesData(); + enabledAttributesData.setAttributeValue(DELAYED_SETTLEMENT.getAttributeValue()); + + ExternalTransferLoanProductAttributesData disabledAttributesData = new ExternalTransferLoanProductAttributesData(); + disabledAttributesData.setAttributeValue(DEFAULT_SETTLEMENT.getAttributeValue()); + + return Stream.of(Arguments.of(new Page(List.of(enabledAttributesData), 1), true), + Arguments.of(new Page(List.of(disabledAttributesData), 1), false), Arguments.of(new Page(List.of(), 0), false)); + } + + @ParameterizedTest + @MethodSource("attributesDataProvider") + void isEnabled(final Page attributesDataPage, final boolean expectedResult) { + // given + TestContext testContext = new TestContext(); + + when(testContext.externalAssetOwnerLoanProductAttributesReadService.retrieveAllLoanProductAttributesByLoanProductId(LOAN_PRODUCT_ID, + DELAYED_SETTLEMENT.getAttributeKey())).thenReturn(attributesDataPage); + + // when + boolean result = testContext.testSubject.isEnabled(LOAN_PRODUCT_ID); + + // then + assertEquals(expectedResult, result); + } + + private static class TestContext { + + @Mock + private ExternalAssetOwnerLoanProductAttributesReadService externalAssetOwnerLoanProductAttributesReadService; + + @InjectMocks + private DelayedSettlementAttributeServiceImpl testSubject; + + TestContext() { + MockitoAnnotations.openMocks(this); + } + } +} diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceTest.java new file mode 100644 index 000000000..5edaaad9d --- /dev/null +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceTest.java @@ -0,0 +1,932 @@ +/** + * 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.investor.service; + +import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.google.gson.JsonElement; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.fineract.cob.data.LoanDataForExternalTransfer; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.investor.data.ExternalTransferRequestParameters; +import org.apache.fineract.investor.data.ExternalTransferStatus; +import org.apache.fineract.investor.domain.ExternalAssetOwner; +import org.apache.fineract.investor.domain.ExternalAssetOwnerRepository; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository; +import org.apache.fineract.investor.exception.ExternalAssetOwnerInitiateTransferException; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException; +import org.junit.Assert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +public class ExternalAssetOwnersWriteServiceTest { + + final LocalDate actualDate = LocalDate.now(ZoneId.systemDefault()); + private static final LocalDate FUTURE_DATE_9999_12_31 = LocalDate.of(9999, 12, 31); + + @BeforeEach + public void setUp() { + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BUSINESS_DATE, actualDate))); + } + + @Test + public void testIntermediarySaleLoanByLoanIdHappyPath() { + TestContext testContext = new TestContext(); + ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))) + .thenReturn(Optional.of(testContext.externalAssetOwner)); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + + // when + testContext.externalAssetOwnersWriteServiceImpl.intermediarySaleLoanByLoanId(command); + + // then + verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(externalAssetOwnerTransferArgumentCaptor.capture()); + verify(testContext.externalAssetOwnerRepository, times(0)).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + + assertAssertOwnerTransferValues(testContext, externalAssetOwnerTransferArgumentCaptor.getValue(), + ExternalTransferStatus.PENDING_INTERMEDIATE); + } + + @Test + public void testIntermediarySaleLoanByLoanIdDelayedSettlementIsNotEnabled() { + TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(false); + + // when + ExternalAssetOwnerInitiateTransferException thrownException = Assert.assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.intermediarySaleLoanByLoanId(command)); + + // then + verify(testContext.externalAssetOwnerTransferRepository, times(0)).saveAndFlush(any(ExternalAssetOwnerTransfer.class)); + verify(testContext.externalAssetOwnerRepository, times(0)).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + Assertions.assertEquals(thrownException.getMessage(), "Delayed Settlement Configuration is not enabled for the loan product: " + + testContext.loanDataForExternalTransfer.getLoanProductShortName()); + } + + @ParameterizedTest + @MethodSource("effectiveTransferDataProviderIntermediarySaleTests") + public void testValidateEffectiveTransferForIntermediarySale(final String testName, + final List externalAssetOwnerTransferList, final String expectedErrorString) { + TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class))).thenReturn(externalAssetOwnerTransferList); + + // when + ExternalAssetOwnerInitiateTransferException thrownException = Assert.assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.intermediarySaleLoanByLoanId(command)); + + // then + verify(testContext.externalAssetOwnerTransferRepository, times(0)).saveAndFlush(any(ExternalAssetOwnerTransfer.class)); + verify(testContext.externalAssetOwnerRepository, times(0)).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + Assertions.assertEquals(thrownException.getMessage(), expectedErrorString); + } + + @ParameterizedTest + @MethodSource("loanStatusValidationDataProviderValidActive") + public void testValidateValidActiveLoanStatus(final String testName, final LoanStatus loanStatus) { + TestContext testContext = new TestContext(); + ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))) + .thenReturn(Optional.of(testContext.externalAssetOwner)); + when(testContext.loanDataForExternalTransfer.getLoanStatus()).thenReturn(loanStatus.getValue()); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + + // when + testContext.externalAssetOwnersWriteServiceImpl.intermediarySaleLoanByLoanId(command); + + // then + verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(externalAssetOwnerTransferArgumentCaptor.capture()); + verify(testContext.externalAssetOwnerRepository, times(0)).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + + assertAssertOwnerTransferValues(testContext, externalAssetOwnerTransferArgumentCaptor.getValue(), + ExternalTransferStatus.PENDING_INTERMEDIATE); + } + + @ParameterizedTest + @MethodSource("loanStatusValidationDataProviderInvalidActive") + public void testValidateInvalidActiveLoanStatus(final String testName, final LoanStatus loanStatus) { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanDataForExternalTransfer.getLoanStatus()).thenReturn(loanStatus.getValue()); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + + // when + ExternalAssetOwnerInitiateTransferException exception = assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.intermediarySaleLoanByLoanId(command)); + + assertEquals(exception.getMessage(), String.format("Loan status %s is not valid for transfer.", loanStatus.name())); + + // then + verify(testContext.fromApiJsonHelper, times(2)).parse(command.json()); + verify(testContext.loanRepository, times(1)).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + verify(testContext.externalAssetOwnerTransferRepository, times(1)).exists(any(Specification.class)); + verifyNoInteractions(testContext.externalAssetOwnerRepository); + } + + @ParameterizedTest + @MethodSource("loanStatusValidationDataProviderValidDelayedSettlement") + public void testValidateValidDelayedSettlementLoanStatus(final String testName, final LoanStatus loanStatus) { + final ExternalAssetOwnerTransfer effectiveTransferForDelayedSettlement = new ExternalAssetOwnerTransfer(); + effectiveTransferForDelayedSettlement.setStatus(ExternalTransferStatus.ACTIVE_INTERMEDIATE); + List assetOwnerTransfers = new ArrayList<>(); + assetOwnerTransfers.add(effectiveTransferForDelayedSettlement); + final TestContext testContext = new TestContext(); + final ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanDataForExternalTransfer.getLoanStatus()).thenReturn(loanStatus.getValue()); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class))).thenReturn(assetOwnerTransfers); + + // when + CommandProcessingResult result = testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command); + + // then + verify(testContext.externalAssetOwnerRepository).findByExternalId(any(ExternalId.class)); + verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(externalAssetOwnerTransferArgumentCaptor.capture()); + verify(testContext.externalAssetOwnerRepository, times(0)).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.externalAssetOwnerTransferRepository).findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + + ExternalAssetOwnerTransfer savedTransfer = externalAssetOwnerTransferArgumentCaptor.getValue(); + assertAssertOwnerTransferValues(testContext, savedTransfer, ExternalTransferStatus.PENDING); + + assertEquals(savedTransfer.getId(), result.getResourceId()); + assertEquals(savedTransfer.getExternalId(), result.getResourceExternalId()); + assertEquals(savedTransfer.getLoanId(), result.getSubResourceId()); + assertEquals(savedTransfer.getExternalLoanId(), result.getSubResourceExternalId()); + } + + @ParameterizedTest + @MethodSource("loanStatusValidationDataProviderInvalidDelayedSettlement") + public void testValidateInvalidDelayedSettlementLoanStatus(final String testName, final LoanStatus loanStatus) { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanDataForExternalTransfer.getLoanStatus()).thenReturn(loanStatus.getValue()); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + + // when + ExternalAssetOwnerInitiateTransferException exception = assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command)); + + assertEquals(exception.getMessage(), String.format("Loan status %s is not valid for transfer.", loanStatus.name())); + + // then + verify(testContext.fromApiJsonHelper, times(2)).parse(command.json()); + verify(testContext.loanRepository, times(1)).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + verify(testContext.externalAssetOwnerTransferRepository, times(1)).exists(any(Specification.class)); + verifyNoInteractions(testContext.externalAssetOwnerRepository); + } + + @ParameterizedTest + @MethodSource("effectiveTransferDataProvider") + public void verifyWhenLoanSaleIsInitiatedThenAssetOwnerTransferIsCreated( + final List externalAssetOwnerTransferList, final boolean isDelayedSettlementEnabled) { + final TestContext testContext = new TestContext(); + final ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(isDelayedSettlementEnabled); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class))).thenReturn(externalAssetOwnerTransferList); + + // when + CommandProcessingResult result = testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command); + + // then + verify(testContext.externalAssetOwnerRepository).findByExternalId(any(ExternalId.class)); + verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(externalAssetOwnerTransferArgumentCaptor.capture()); + verify(testContext.externalAssetOwnerRepository, times(0)).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.externalAssetOwnerTransferRepository).findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + + ExternalAssetOwnerTransfer savedTransfer = externalAssetOwnerTransferArgumentCaptor.getValue(); + assertAssertOwnerTransferValues(testContext, savedTransfer, ExternalTransferStatus.PENDING); + + assertEquals(savedTransfer.getId(), result.getResourceId()); + assertEquals(savedTransfer.getExternalId(), result.getResourceExternalId()); + assertEquals(savedTransfer.getLoanId(), result.getSubResourceId()); + assertEquals(savedTransfer.getExternalLoanId(), result.getSubResourceExternalId()); + } + + @Test + public void validateSettlementDateInThePastTest() { + TestContext testContext = new TestContext(); + final JsonElement jsonCommandElement = testContext.fromJsonHelper.parse(testContext.jsonCommand); + ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + + lenient().when( + testContext.fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE, jsonCommandElement)) + .thenReturn(LocalDate.EPOCH); + lenient().when(testContext.fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE, + jsonCommandElement, testContext.DATE_FORMAT, Locale.GERMANY)).thenReturn(LocalDate.EPOCH); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + + // when + ExternalAssetOwnerInitiateTransferException thrownException = Assert.assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.intermediarySaleLoanByLoanId(command)); + + // then + verify(testContext.externalAssetOwnerTransferRepository, times(0)).saveAndFlush(externalAssetOwnerTransferArgumentCaptor.capture()); + verify(testContext.externalAssetOwnerRepository, times(0)).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + Assertions.assertEquals(thrownException.getMessage(), "Settlement date cannot be in the past"); + } + + private static Stream effectiveTransferDataProvider() { + final ExternalAssetOwnerTransfer effectiveTransferForDelayedSettlement = new ExternalAssetOwnerTransfer(); + effectiveTransferForDelayedSettlement.setStatus(ExternalTransferStatus.ACTIVE_INTERMEDIATE); + + return Stream.of(Arguments.of(Collections.emptyList(), false), Arguments.of(List.of(effectiveTransferForDelayedSettlement), true)); + } + + private static Stream effectiveTransferDataProviderIntermediarySaleTests() { + final ExternalAssetOwnerTransfer activeIntermediate = new ExternalAssetOwnerTransfer(); + activeIntermediate.setStatus(ExternalTransferStatus.ACTIVE_INTERMEDIATE); + final ExternalAssetOwnerTransfer active = new ExternalAssetOwnerTransfer(); + active.setStatus(ExternalTransferStatus.ACTIVE); + final ExternalAssetOwnerTransfer pendingIntermediate = new ExternalAssetOwnerTransfer(); + pendingIntermediate.setStatus(ExternalTransferStatus.PENDING_INTERMEDIATE); + + return Stream.of( + Arguments.of("Incorrect State", List.of(activeIntermediate), + String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)", + activeIntermediate.getId())), + Arguments.of("Already In Progress", List.of(activeIntermediate, active), + "This loan cannot be sold, there is already an in progress transfer"), + Arguments.of("Already Pending Intermediary", List.of(pendingIntermediate), + "External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan"), + Arguments.of("Already Owned by External Asset Owner", List.of(active), + "This loan cannot be sold, because it is owned by an external asset owner")); + } + + private static Stream loanStatusValidationDataProviderValidActive() { + return Stream.of(Arguments.of("Active Loan Status", LoanStatus.ACTIVE), + Arguments.of("Transfer In Progress Loan Status", LoanStatus.TRANSFER_IN_PROGRESS), + Arguments.of("Transfer On Hold Loan Status", LoanStatus.TRANSFER_ON_HOLD)); + } + + private static Stream loanStatusValidationDataProviderValidDelayedSettlement() { + return Stream.of(Arguments.of("Active Loan Status", LoanStatus.ACTIVE), + Arguments.of("Transfer On Hold Loan Status", LoanStatus.TRANSFER_ON_HOLD), + Arguments.of("Transfer In Progress Status", LoanStatus.TRANSFER_IN_PROGRESS), + Arguments.of("Overpaid Loan Status", LoanStatus.OVERPAID), + Arguments.of("Closed Obligations Met Loan Status", LoanStatus.CLOSED_OBLIGATIONS_MET)); + } + + private static Stream loanStatusValidationDataProviderInvalidActive() { + return Stream.of(Arguments.of("Invalid Loan Status", LoanStatus.INVALID), Arguments.of("Overpaid Loan Status", LoanStatus.OVERPAID), + Arguments.of("Approved Loan Status", LoanStatus.APPROVED), Arguments.of("Rejected Loan Status", LoanStatus.REJECTED), + Arguments.of("Submitted and Pending Approval Loan Status", LoanStatus.SUBMITTED_AND_PENDING_APPROVAL), + Arguments.of("Withdrawn By Client Loan Status", LoanStatus.WITHDRAWN_BY_CLIENT), + Arguments.of("Closed Written Off Loan Status", LoanStatus.CLOSED_WRITTEN_OFF), + Arguments.of("Closed Reschedule Outstanding Amount Loan Status", LoanStatus.CLOSED_RESCHEDULE_OUTSTANDING_AMOUNT), + Arguments.of("Closed Obligations Met Loan Status", LoanStatus.CLOSED_OBLIGATIONS_MET)); + } + + private static Stream loanStatusValidationDataProviderInvalidDelayedSettlement() { + return Stream.of(Arguments.of("Invalid Loan Status", LoanStatus.INVALID), Arguments.of("Approved Loan Status", LoanStatus.APPROVED), + Arguments.of("Rejected Loan Status", LoanStatus.REJECTED), + Arguments.of("Submitted and Pending Approval Loan Status", LoanStatus.SUBMITTED_AND_PENDING_APPROVAL), + Arguments.of("Withdrawn By Client Loan Status", LoanStatus.WITHDRAWN_BY_CLIENT), + Arguments.of("Closed Written Off Loan Status", LoanStatus.CLOSED_WRITTEN_OFF), + Arguments.of("Closed Reschedule Outstanding Amount Loan Status", LoanStatus.CLOSED_RESCHEDULE_OUTSTANDING_AMOUNT)); + } + + @Test + public void verifyWhenLoanSaleIsInitiatedWithoutOwnerThenAssetOwnerTransferIsCreated() { + final TestContext testContext = new TestContext(); + final ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerTransfer.class); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(false); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))).thenReturn(Optional.empty()); + + // when + CommandProcessingResult result = testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command); + + // then + verify(testContext.externalAssetOwnerRepository).findByExternalId(any(ExternalId.class)); + verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(externalAssetOwnerTransferArgumentCaptor.capture()); + verify(testContext.externalAssetOwnerRepository).saveAndFlush(any(ExternalAssetOwner.class)); + verify(testContext.externalAssetOwnerTransferRepository).findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class)); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + + ExternalAssetOwnerTransfer savedTransfer = externalAssetOwnerTransferArgumentCaptor.getValue(); + assertAssertOwnerTransferValues(testContext, savedTransfer, ExternalTransferStatus.PENDING); + + assertEquals(savedTransfer.getId(), result.getResourceId()); + assertEquals(savedTransfer.getExternalId(), result.getResourceExternalId()); + assertEquals(savedTransfer.getLoanId(), result.getSubResourceId()); + assertEquals(savedTransfer.getExternalLoanId(), result.getSubResourceExternalId()); + } + + @Test + public void verifyWhenLoanIsNotFoundThenExceptionIsThrown() { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)).thenReturn(Optional.empty()); + + // when + assertThrows(LoanNotFoundException.class, () -> testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command)); + + // then + verify(testContext.fromApiJsonHelper, times(1)).parse(command.json()); + verifyNoMoreInteractions(testContext.loanRepository); + verifyNoInteractions(testContext.externalAssetOwnerRepository, testContext.externalAssetOwnerTransferRepository, + testContext.delayedSettlementAttributeService); + } + + @ParameterizedTest + @MethodSource("invalidFieldsDataProvider") + public void verifyWhenFieldValueInvalidThenExceptionIsThrown(final String ownerExternalId, final String transferExternalId, + final String purchaseRatio, final LocalDate settlementDate) { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + final JsonElement jsonCommandElement = testContext.fromJsonHelper.parse(testContext.jsonCommand); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(false); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, jsonCommandElement)) + .thenReturn(ownerExternalId); + when(testContext.fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_ID, jsonCommandElement)) + .thenReturn(transferExternalId); + when(testContext.fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, jsonCommandElement)) + .thenReturn(purchaseRatio); + when(testContext.fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE, jsonCommandElement)) + .thenReturn(settlementDate); + + // when + assertThrows(PlatformApiDataValidationException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command)); + + // then + verify(testContext.fromApiJsonHelper, times(2)).parse(command.json()); + verifyNoMoreInteractions(testContext.loanRepository); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + verifyNoInteractions(testContext.externalAssetOwnerRepository, testContext.externalAssetOwnerTransferRepository); + } + + private static Stream invalidFieldsDataProvider() { + // ownerExternalId, transferExternalId, purchaseRatio, settlementDate + return Stream.of( + // settlement date cannot be null + Arguments.of("value", "value", "value", null), + // purchaseRatio cannot be null + Arguments.of("value", "value", null, LocalDate.now(ZoneId.systemDefault()).plusDays(1)), + // purchaseRatio length cannot be > 50 + Arguments.of("value", "value", RandomStringUtils.randomAlphanumeric(51), LocalDate.now(ZoneId.systemDefault()).plusDays(1)), + // transferExternalId length cannot be > 100 + Arguments.of("value", RandomStringUtils.randomAlphanumeric(101), "value", + LocalDate.now(ZoneId.systemDefault()).plusDays(1)), + // ownerExternalId cannot be null + Arguments.of(null, "value", "value", LocalDate.now(ZoneId.systemDefault()).plusDays(1)), + // ownerExternalId length cannot be > 100 + Arguments.of(RandomStringUtils.randomAlphanumeric(101), "value", "value", + LocalDate.now(ZoneId.systemDefault()).plusDays(1))); + } + + @Test + public void verifyWhenTransferExternalIdExistsThenExceptionIsThrown() { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(false); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerTransferRepository.exists(any(Specification.class))).thenReturn(true); + + // when + assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command)); + + // then + verify(testContext.fromApiJsonHelper, times(2)).parse(command.json()); + verify(testContext.loanRepository, times(1)).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + verify(testContext.externalAssetOwnerTransferRepository, times(1)).exists(any(Specification.class)); + verifyNoInteractions(testContext.externalAssetOwnerRepository); + } + + @ParameterizedTest + @MethodSource("delayedSettlementFlagDataProvider") + public void verifyWhenTooManyEffectiveTransfersThenExceptionIsThrown(final boolean isDelayedSettlementEnabled) { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(isDelayedSettlementEnabled); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class))).thenReturn(List.of(new ExternalAssetOwnerTransfer(), new ExternalAssetOwnerTransfer())); + + // when + ExternalAssetOwnerInitiateTransferException exception = assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command)); + + assertEquals(exception.getMessage(), "This loan cannot be sold, there is already an in progress transfer"); + + // then + verify(testContext.fromApiJsonHelper, times(2)).parse(command.json()); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + verify(testContext.externalAssetOwnerTransferRepository).exists(any(Specification.class)); + verify(testContext.externalAssetOwnerTransferRepository, times(1)).findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class)); + verify(testContext.externalAssetOwnerRepository).findByExternalId(any(ExternalId.class)); + } + + private static Stream delayedSettlementFlagDataProvider() { + return Stream.of(Arguments.of(false), Arguments.of(true)); + } + + @Test + public void verifyWhenNoEffectiveTransfersWithDelayedSettlementThenExceptionIsThrown() { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class))).thenReturn(Collections.emptyList()); + + // when + ExternalAssetOwnerInitiateTransferException exception = assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command)); + + assertEquals(exception.getMessage(), "This loan cannot be sold, no effective transfer found."); + // then + verify(testContext.fromApiJsonHelper, times(2)).parse(command.json()); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + verify(testContext.externalAssetOwnerTransferRepository).exists(any(Specification.class)); + verify(testContext.externalAssetOwnerTransferRepository, times(1)).findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class)); + verify(testContext.externalAssetOwnerRepository).findByExternalId(any(ExternalId.class)); + } + + @ParameterizedTest + @MethodSource("invalidTransferStatusDataProvider") + public void verifyWhenInvalidTransferStatusThenExceptionIsThrown(final ExternalTransferStatus externalTransferStatus, + final boolean isDelayedSettlementEnabled, final String expectedExceptionMessage) { + final TestContext testContext = new TestContext(); + + // given + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + final ExternalAssetOwnerTransfer externalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); + externalAssetOwnerTransfer.setStatus(externalTransferStatus); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(isDelayedSettlementEnabled); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class))).thenReturn(List.of(externalAssetOwnerTransfer)); + + // when + ExternalAssetOwnerInitiateTransferException exception = assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command)); + + assertEquals(exception.getMessage(), expectedExceptionMessage); + + // then + verify(testContext.fromApiJsonHelper, times(2)).parse(command.json()); + verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + verify(testContext.externalAssetOwnerTransferRepository).exists(any(Specification.class)); + verify(testContext.externalAssetOwnerTransferRepository, times(1)).findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId), + any(LocalDate.class)); + verify(testContext.externalAssetOwnerRepository).findByExternalId(any(ExternalId.class)); + } + + private static Stream invalidTransferStatusDataProvider() { + return Stream.of( + Arguments.of(ExternalTransferStatus.PENDING, false, + "External asset owner transfer is already in PENDING state for this loan"), + Arguments.of(ExternalTransferStatus.ACTIVE, false, + "This loan cannot be sold, because it is owned by an external asset owner"), + Arguments.of(ExternalTransferStatus.PENDING_INTERMEDIATE, true, + "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state."), + Arguments.of(ExternalTransferStatus.ACTIVE, true, + "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state."), + Arguments.of(ExternalTransferStatus.DECLINED, true, + "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state."), + Arguments.of(ExternalTransferStatus.PENDING, true, + "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state."), + Arguments.of(ExternalTransferStatus.BUYBACK, true, + "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state."), + Arguments.of(ExternalTransferStatus.CANCELLED, true, + "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state.")); + } + + @ParameterizedTest + @MethodSource("buybackValidationWithDelaySettlementSuccessfulDataProvider") + void buybackLoanByLoanIdWhenDelaySettlementEnabledSuccess(final List transferStatuses, + final ExternalTransferStatus expectedStatus) { + // given + TestContext testContext = new TestContext(); + + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + + List transfers = transferStatuses.stream() + .map(transferStatus -> createExternalAssetOwnerTransfer(testContext, transferStatus)).toList(); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(testContext.loanId, actualDate)) + .thenReturn(transfers); + + // when + CommandProcessingResult result = testContext.externalAssetOwnersWriteServiceImpl.buybackLoanByLoanId(command); + + // then + ArgumentCaptor savedTransferCaptor = ArgumentCaptor.forClass(ExternalAssetOwnerTransfer.class); + verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(savedTransferCaptor.capture()); + + ExternalAssetOwnerTransfer savedTransfer = savedTransferCaptor.getValue(); + assertNotNull(savedTransfer); + assertFalse(transfers.isEmpty()); + ExternalAssetOwnerTransfer expectedEffectiveTransfer = transfers.get(0); + assertEquals(testContext.transferExternalId, savedTransfer.getExternalId().getValue()); + assertEquals(expectedEffectiveTransfer.getOwner(), savedTransfer.getOwner()); + assertEquals(expectedStatus, savedTransfer.getStatus()); + assertEquals(expectedEffectiveTransfer.getLoanId(), savedTransfer.getLoanId()); + assertEquals(expectedEffectiveTransfer.getExternalLoanId(), savedTransfer.getExternalLoanId()); + assertEquals(testContext.settlementDate, savedTransfer.getSettlementDate()); + assertEquals(actualDate, savedTransfer.getEffectiveDateFrom()); + assertEquals(FUTURE_DATE_9999_12_31, savedTransfer.getEffectiveDateTo()); + assertEquals(expectedEffectiveTransfer.getPurchasePriceRatio(), savedTransfer.getPurchasePriceRatio()); + + assertEquals(savedTransfer.getId(), result.getResourceId()); + assertEquals(savedTransfer.getExternalId(), result.getResourceExternalId()); + assertEquals(savedTransfer.getLoanId(), result.getSubResourceId()); + assertEquals(savedTransfer.getExternalLoanId(), result.getSubResourceExternalId()); + + verifyNoInteractions(testContext.externalAssetOwnerRepository); + } + + private static Stream buybackValidationWithDelaySettlementSuccessfulDataProvider() { + return Stream.of(Arguments.of(List.of(ExternalTransferStatus.ACTIVE_INTERMEDIATE), ExternalTransferStatus.BUYBACK_INTERMEDIATE), + Arguments.of(List.of(ExternalTransferStatus.ACTIVE), ExternalTransferStatus.BUYBACK)); + } + + @ParameterizedTest + @MethodSource("buybackValidationWithDelaySettlementFailureDataProvider") + void buybackLoanByLoanIdWhenDelaySettlementEnabledFailure(final List transferStatuses, + final String expectedExceptionMessage) { + // given + TestContext testContext = new TestContext(); + + final JsonCommand command = createJsonCommand(testContext.jsonCommand, testContext.loanId); + + when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(true); + when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId)) + .thenReturn(Optional.of(testContext.loanDataForExternalTransfer)); + + List transfers = transferStatuses.stream() + .map(transferStatus -> createExternalAssetOwnerTransfer(testContext, transferStatus)).toList(); + when(testContext.externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(testContext.loanId, actualDate)) + .thenReturn(transfers); + + // when + ExternalAssetOwnerInitiateTransferException actualException = assertThrows(ExternalAssetOwnerInitiateTransferException.class, + () -> testContext.externalAssetOwnersWriteServiceImpl.buybackLoanByLoanId(command)); + + // then + assertEquals(expectedExceptionMessage, actualException.getMessage()); + verify(testContext.externalAssetOwnerTransferRepository, never()).saveAndFlush(any()); + verifyNoInteractions(testContext.externalAssetOwnerRepository); + verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId); + } + + private static Stream buybackValidationWithDelaySettlementFailureDataProvider() { + return Stream.of( + Arguments.of(Collections.emptyList(), "This loan cannot be bought back, it is not owned by an external asset owner"), + Arguments.of(List.of(ExternalTransferStatus.PENDING_INTERMEDIATE), + "This loan cannot be bought back, effective transfer is not in right state: PENDING_INTERMEDIATE"), + Arguments.of(List.of(ExternalTransferStatus.PENDING), + "This loan cannot be bought back, effective transfer is not in right state: PENDING"), + Arguments.of(List.of(ExternalTransferStatus.DECLINED), + "This loan cannot be bought back, effective transfer is not in right state: DECLINED"), + Arguments.of(List.of(ExternalTransferStatus.BUYBACK), + "This loan cannot be bought back, effective transfer is not in right state: BUYBACK"), + Arguments.of(List.of(ExternalTransferStatus.BUYBACK_INTERMEDIATE), + "This loan cannot be bought back, effective transfer is not in right state: BUYBACK_INTERMEDIATE"), + Arguments.of(List.of(ExternalTransferStatus.CANCELLED), + "This loan cannot be bought back, effective transfer is not in right state: CANCELLED"), + Arguments.of(List.of(ExternalTransferStatus.ACTIVE_INTERMEDIATE, ExternalTransferStatus.PENDING), + "This loan cannot be bought back, external asset owner sale is pending"), + Arguments.of(List.of(ExternalTransferStatus.PENDING, ExternalTransferStatus.ACTIVE_INTERMEDIATE), + "This loan cannot be bought back, external asset owner sale is pending"), + Arguments.of(List.of(ExternalTransferStatus.ACTIVE_INTERMEDIATE, ExternalTransferStatus.BUYBACK_INTERMEDIATE), + "This loan cannot be bought back, external asset owner buyback transfer is already in progress"), + Arguments.of(List.of(ExternalTransferStatus.BUYBACK_INTERMEDIATE, ExternalTransferStatus.ACTIVE_INTERMEDIATE), + "This loan cannot be bought back, external asset owner buyback transfer is already in progress"), + Arguments.of(List.of(ExternalTransferStatus.ACTIVE, ExternalTransferStatus.BUYBACK), + "This loan cannot be bought back, external asset owner buyback transfer is already in progress"), + Arguments.of(List.of(ExternalTransferStatus.BUYBACK, ExternalTransferStatus.ACTIVE), + "This loan cannot be bought back, external asset owner buyback transfer is already in progress")); + } + + /** + * Helper method to create {@link JsonCommand} object from json command string. + * + * @param jsonCommand + * the json command string + * @param loanId + * the loan id + * @return the {@link JsonCommand} object. + */ + private JsonCommand createJsonCommand(final String jsonCommand, final Long loanId) { + return new JsonCommand(null, jsonCommand, null, null, null, null, null, null, null, loanId, null, null, null, null, null, null, + null, null); + } + + /** + * Helper method to create {@link ExternalAssetOwnerTransfer} object. + * + * @param status + * the {@link ExternalTransferStatus} + * @return the {@link ExternalAssetOwnerTransfer} object. + */ + private ExternalAssetOwnerTransfer createExternalAssetOwnerTransfer(final TestContext testContext, + final ExternalTransferStatus status) { + ExternalAssetOwnerTransfer transfer = new ExternalAssetOwnerTransfer(); + transfer.setExternalId(new ExternalId(RandomStringUtils.randomAlphanumeric(10))); + transfer.setOwner(new ExternalAssetOwner()); + transfer.setStatus(status); + transfer.setLoanId(testContext.loanId); + transfer.setExternalLoanId(new ExternalId(testContext.externalLoanId)); + transfer.setExternalGroupId(new ExternalId(testContext.externalLoanId)); + transfer.setPurchasePriceRatio(TestContext.PURCHASE_RATIO.toString()); + + return transfer; + } + + /** + * Asserts on the {@link ExternalAssetOwnerTransfer} object values against test values. + * + * @param testContext + * the test context with expected values. + * @param externalAssetOwnerTransfer + * the {@link ExternalAssetOwnerTransfer} object. + * @param expectedTransferStatus + * the expected transfer status. + */ + private void assertAssertOwnerTransferValues(final TestContext testContext, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, + final ExternalTransferStatus expectedTransferStatus) { + assertEquals(testContext.loanId, externalAssetOwnerTransfer.getLoanId()); + assertEquals(testContext.externalLoanId, externalAssetOwnerTransfer.getExternalLoanId().getValue()); + assertEquals(testContext.ownerExternalId, externalAssetOwnerTransfer.getOwner().getExternalId().getValue()); + assertEquals(testContext.transferExternalId, externalAssetOwnerTransfer.getExternalId().getValue()); + assertEquals(testContext.transferExternalGroupId, externalAssetOwnerTransfer.getExternalGroupId().getValue()); + assertEquals(expectedTransferStatus, externalAssetOwnerTransfer.getStatus()); + assertEquals(TestContext.PURCHASE_RATIO.toString(), externalAssetOwnerTransfer.getPurchasePriceRatio()); + assertEquals(testContext.settlementDate, externalAssetOwnerTransfer.getSettlementDate()); + assertEquals(actualDate, externalAssetOwnerTransfer.getEffectiveDateFrom()); + assertEquals(FUTURE_DATE_9999_12_31, externalAssetOwnerTransfer.getEffectiveDateTo()); + } + + @SuppressFBWarnings({ "VA_FORMAT_STRING_USES_NEWLINE" }) + static class TestContext { + + @Mock + private ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository; + + @Mock + private ExternalAssetOwnerRepository externalAssetOwnerRepository; + + @Mock + private FromJsonHelper fromApiJsonHelper; + + @Mock + private LoanRepository loanRepository; + + @Mock + private DelayedSettlementAttributeService delayedSettlementAttributeService; + + @Mock + private LoanDataForExternalTransfer loanDataForExternalTransfer; + + @InjectMocks + private ExternalAssetOwnersWriteServiceImpl externalAssetOwnersWriteServiceImpl; + + private static final BigDecimal PURCHASE_RATIO = BigDecimal.valueOf(Float.parseFloat(RandomStringUtils.randomNumeric(1, 3)) / 100) + .setScale(2, RoundingMode.HALF_UP); + private static final String DATE_FORMAT = "yyyy-MM-dd"; + private static final String LOCALE = "de_DE"; + + private final FromJsonHelper fromJsonHelper = new FromJsonHelper(); + private final ExternalAssetOwner externalAssetOwner = new ExternalAssetOwner(); + private final Long loanId = Long.valueOf(RandomStringUtils.randomNumeric(2)); + private final String externalLoanId = RandomStringUtils.randomAlphanumeric(10); + private final Long loanProductId = Long.valueOf(RandomStringUtils.randomNumeric(2)); + private final String loanProductShortName = RandomStringUtils.randomAlphanumeric(10); + private final String ownerExternalId = RandomStringUtils.randomAlphanumeric(10); + private final String transferExternalId = RandomStringUtils.randomAlphanumeric(10); + private final String transferExternalGroupId = RandomStringUtils.randomAlphanumeric(10); + private final LocalDate settlementDate = LocalDate.parse("9999-08-22"); + private final String jsonCommand = String.format(""" + { + "settlementDate": "%s", + "ownerExternalId": "%s", + "transferExternalId": "%s", + "transferExternalGroupId": "%s", + "purchasePriceRatio": "%s", + "dateFormat": "%s", + "locale": "%s" + } + """, settlementDate, ownerExternalId, transferExternalId, transferExternalGroupId, PURCHASE_RATIO, DATE_FORMAT, LOCALE); + + @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") + TestContext() { + MockitoAnnotations.openMocks(this); + final JsonElement jsonCommandElement = fromJsonHelper.parse(jsonCommand); + externalAssetOwner.setExternalId(new ExternalId(ownerExternalId)); + + lenient().when(fromApiJsonHelper.parse(anyString())).thenReturn(jsonCommandElement); + lenient().when(fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, jsonCommandElement)) + .thenReturn(ownerExternalId); + lenient().when(fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_ID, jsonCommandElement)) + .thenReturn(transferExternalId); + lenient().when( + fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.TRANSFER_EXTERNAL_GROUP_ID, jsonCommandElement)) + .thenReturn(transferExternalGroupId); + lenient().when(fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, jsonCommandElement)) + .thenReturn(PURCHASE_RATIO.toString()); + lenient().when(fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.DATEFORMAT, jsonCommandElement)) + .thenReturn(DATE_FORMAT); + lenient().when(fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.LOCALE, jsonCommandElement)) + .thenReturn(LOCALE); + lenient().when(fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE, jsonCommandElement)) + .thenReturn(settlementDate); + lenient().when(fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE, jsonCommandElement, + DATE_FORMAT, Locale.GERMANY)).thenReturn(settlementDate); + + lenient().when(externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))) + .thenReturn(Optional.of(externalAssetOwner)); + lenient().when(externalAssetOwnerTransferRepository.findEffectiveTransfersOrderByIdDesc(eq(loanId), any(LocalDate.class))) + .thenReturn(Collections.emptyList()); + lenient().when(externalAssetOwnerRepository.saveAndFlush(any(ExternalAssetOwner.class))).thenReturn(externalAssetOwner); + + lenient().when(loanDataForExternalTransfer.getId()).thenReturn(loanId); + lenient().when(loanDataForExternalTransfer.getExternalId()).thenReturn(new ExternalId(externalLoanId)); + lenient().when(loanDataForExternalTransfer.getLoanStatus()).thenReturn(LoanStatus.ACTIVE.getValue()); + lenient().when(loanDataForExternalTransfer.getLoanProductId()).thenReturn(loanProductId); + lenient().when(loanDataForExternalTransfer.getLoanProductShortName()).thenReturn(loanProductShortName); + } + } +} diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceTest.java index 74b1991a2..430ff33a9 100644 --- a/fineract-investor/src/test/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceTest.java +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/LoanAccountOwnerTransferServiceTest.java @@ -19,8 +19,6 @@ package org.apache.fineract.investor.service; import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; -import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK; -import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -36,10 +34,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccountSnapshotBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.investor.data.ExternalTransferStatus; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMappingRepository; import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository; @@ -50,6 +50,9 @@ import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; @@ -80,38 +83,88 @@ public class LoanAccountOwnerTransferServiceTest { } @Test - public void verifyWhenCancelPendingSaleAndBuybackTransferThenBusinessEventsAreSent() { + public void verifyWhenCancelPendingAndIntermediateSaleAndBuybackTransferThenBusinessEventsAreSent() { // given final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); ExternalAssetOwnerTransfer pendingSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); ExternalAssetOwnerTransfer pendingBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer pendingIntermediateTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer pendingBuyBackIntermediateTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); ExternalAssetOwnerTransfer cancelledSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); ExternalAssetOwnerTransfer cancelledBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer cancelledIntermediateTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer cancelledBuyBackIntermediateSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); - List response = List.of(pendingSaleTransfer, pendingBuybackTransfer); + List response = List.of(pendingSaleTransfer, pendingBuybackTransfer, pendingIntermediateTransfer, + pendingBuyBackIntermediateTransfer); when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(ASC, "id")))).thenReturn(response); when(externalAssetOwnerTransferRepository.save(any(ExternalAssetOwnerTransfer.class))).thenReturn(pendingSaleTransfer) - .thenReturn(cancelledSaleTransfer).thenReturn(pendingBuybackTransfer).thenReturn(cancelledBuybackTransfer); + .thenReturn(cancelledSaleTransfer).thenReturn(pendingBuybackTransfer).thenReturn(cancelledBuybackTransfer) + .thenReturn(pendingIntermediateTransfer).thenReturn(cancelledIntermediateTransfer) + .thenReturn(pendingBuyBackIntermediateTransfer).thenReturn(cancelledBuyBackIntermediateSaleTransfer); // when underTest.handleLoanClosedOrOverpaid(loanForProcessing); // then - ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(2); + ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(4); verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, cancelledSaleTransfer); verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing, cancelledBuybackTransfer); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 2, loanForProcessing, cancelledIntermediateTransfer); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 3, loanForProcessing, cancelledBuyBackIntermediateSaleTransfer); } @Test - public void verifyWhenDeclinePendingSaleTransferThenBusinessEventIsSent() { + public void verifyWhenDeclineCancelPendingAndIntermediateSaleAndBuybackTransferThenBusinessEventsAreSent() { // given final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); ExternalAssetOwnerTransfer pendingSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(pendingSaleTransfer.getStatus()).thenReturn(PENDING); + when(pendingSaleTransfer.getSettlementDate()).thenReturn(actualDate.minusDays(1)); + ExternalAssetOwnerTransfer pendingBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer pendingIntermediateTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer pendingBuyBackIntermediateTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + + when(pendingSaleTransfer.getSettlementDate()).thenReturn(actualDate.minusDays(1)); + when(pendingBuybackTransfer.getSettlementDate()).thenReturn(actualDate.minusDays(1)); + when(pendingIntermediateTransfer.getSettlementDate()).thenReturn(actualDate); + + ExternalAssetOwnerTransfer declinedSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer cancelledBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer cancelledIntermediateTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + ExternalAssetOwnerTransfer cancelledBuyBackIntermediateSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + + List response = List.of(pendingSaleTransfer, pendingBuybackTransfer, pendingIntermediateTransfer, + pendingBuyBackIntermediateTransfer); + when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(ASC, "id")))).thenReturn(response); + when(externalAssetOwnerTransferRepository.save(any(ExternalAssetOwnerTransfer.class))).thenReturn(pendingSaleTransfer) + .thenReturn(declinedSaleTransfer).thenReturn(pendingBuybackTransfer).thenReturn(cancelledBuybackTransfer) + .thenReturn(pendingIntermediateTransfer).thenReturn(cancelledIntermediateTransfer) + .thenReturn(pendingBuyBackIntermediateTransfer).thenReturn(cancelledBuyBackIntermediateSaleTransfer); + + // when + underTest.handleLoanClosedOrOverpaid(loanForProcessing); + + // then + ArgumentCaptor> businessEventArgumentCaptor = verifyBusinessEvents(4); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, pendingSaleTransfer); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing, cancelledBuybackTransfer); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 2, loanForProcessing, cancelledIntermediateTransfer); + verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 3, loanForProcessing, cancelledBuyBackIntermediateSaleTransfer); + } + + @ParameterizedTest + @MethodSource("pendingStatusDataProvider") + public void verifyWhenDeclinePendingSaleTransferThenBusinessEventIsSent(final ExternalTransferStatus pendingStatus) { + // given + final Loan loanForProcessing = Mockito.mock(Loan.class); + when(loanForProcessing.getId()).thenReturn(1L); + + ExternalAssetOwnerTransfer pendingSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); + when(pendingSaleTransfer.getStatus()).thenReturn(pendingStatus); ExternalAssetOwnerTransfer declineTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); List response = List.of(pendingSaleTransfer); @@ -127,8 +180,9 @@ public class LoanAccountOwnerTransferServiceTest { verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, declineTransfer); } - @Test - public void verifyWhenExecutePendingBuybackTransferThenBusinessEventIsSent() { + @ParameterizedTest + @MethodSource("buybackStatusDataProvider") + public void verifyWhenExecutePendingBuybackTransferThenBusinessEventIsSent(final ExternalTransferStatus buybackStatus) { // given final Loan loanForProcessing = Mockito.mock(Loan.class); when(loanForProcessing.getId()).thenReturn(1L); @@ -136,7 +190,7 @@ public class LoanAccountOwnerTransferServiceTest { when(loanForProcessing.getSummary()).thenReturn(loanSummary); ExternalAssetOwnerTransfer pendingBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); - when(pendingBuybackTransfer.getStatus()).thenReturn(BUYBACK); + when(pendingBuybackTransfer.getStatus()).thenReturn(buybackStatus); ExternalAssetOwnerTransfer activeTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class); List response = List.of(pendingBuybackTransfer); @@ -154,6 +208,14 @@ public class LoanAccountOwnerTransferServiceTest { verifyLoanAccountSnapshotBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing); } + private static Stream pendingStatusDataProvider() { + return Stream.of(Arguments.of(ExternalTransferStatus.PENDING_INTERMEDIATE), Arguments.of(ExternalTransferStatus.PENDING)); + } + + private static Stream buybackStatusDataProvider() { + return Stream.of(Arguments.of(ExternalTransferStatus.BUYBACK_INTERMEDIATE), Arguments.of(ExternalTransferStatus.BUYBACK)); + } + @NotNull private ArgumentCaptor> verifyBusinessEvents(int expectedBusinessEvents) { @SuppressWarnings("unchecked") diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/LoanTransferabilityServiceImplTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/LoanTransferabilityServiceImplTest.java new file mode 100644 index 000000000..c34ca123b --- /dev/null +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/LoanTransferabilityServiceImplTest.java @@ -0,0 +1,163 @@ +/** + * 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.investor.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.stream.Stream; +import org.apache.fineract.investor.data.ExternalTransferStatus; +import org.apache.fineract.investor.data.ExternalTransferSubStatus; +import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class LoanTransferabilityServiceImplTest { + + private static final Long LOAN_PRODUCT_ID = 1L; + + private static Stream amountDataProvider() { + return Stream.of(Arguments.of(BigDecimal.ONE, true), Arguments.of(BigDecimal.ZERO, false), + Arguments.of(BigDecimal.ONE.negate(), false)); + } + + @ParameterizedTest + @MethodSource("amountDataProvider") + void isTransferableWhenDelayedSettlementDisabled(final BigDecimal loanOutstandingAmount, final boolean expectedResult) { + // given + TestContext testContext = new TestContext(); + + LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + LoanSummary loanSummary = Mockito.mock(LoanSummary.class); + when(loanSummary.getTotalOutstanding()).thenReturn(loanOutstandingAmount); + + Loan loan = Mockito.mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loan.getSummary()).thenReturn(loanSummary); + when(testContext.delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(false); + + ExternalAssetOwnerTransfer externalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); + externalAssetOwnerTransfer.setStatus(ExternalTransferStatus.PENDING); + + // when + boolean result = testContext.testSubject.isTransferable(loan, externalAssetOwnerTransfer); + + // then + assertEquals(expectedResult, result); + } + + @ParameterizedTest + @MethodSource("amountDataProvider") + void isTransferableWhenDelayedSettlementEnabledAndSellingToIntermediate(final BigDecimal loanOutstandingAmount, + final boolean expectedResult) { + // given + TestContext testContext = new TestContext(); + + LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + LoanSummary loanSummary = Mockito.mock(LoanSummary.class); + when(loanSummary.getTotalOutstanding()).thenReturn(loanOutstandingAmount); + + Loan loan = Mockito.mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loan.getSummary()).thenReturn(loanSummary); + when(testContext.delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(true); + + ExternalAssetOwnerTransfer externalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); + externalAssetOwnerTransfer.setStatus(ExternalTransferStatus.PENDING_INTERMEDIATE); + + // when + boolean result = testContext.testSubject.isTransferable(loan, externalAssetOwnerTransfer); + + // then + assertEquals(expectedResult, result); + } + + @Test + void isTransferableWhenDelayedSettlementEnabledAndSellingToInvestor() { + // given + TestContext testContext = new TestContext(); + + LoanProduct loanProduct = Mockito.mock(LoanProduct.class); + when(loanProduct.getId()).thenReturn(LOAN_PRODUCT_ID); + + Loan loan = Mockito.mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(testContext.delayedSettlementAttributeService.isEnabled(LOAN_PRODUCT_ID)).thenReturn(true); + + ExternalAssetOwnerTransfer externalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); + externalAssetOwnerTransfer.setStatus(ExternalTransferStatus.PENDING); + + // when + boolean result = testContext.testSubject.isTransferable(loan, externalAssetOwnerTransfer); + + // then + assertTrue(result); + } + + private static Stream declinedSubStatusDataProvider() { + return Stream.of(Arguments.of(BigDecimal.ONE, ExternalTransferSubStatus.BALANCE_NEGATIVE), + Arguments.of(BigDecimal.ZERO, ExternalTransferSubStatus.BALANCE_ZERO), + Arguments.of(null, ExternalTransferSubStatus.BALANCE_ZERO)); + } + + @ParameterizedTest + @MethodSource("declinedSubStatusDataProvider") + void getDeclinedSubStatus(final BigDecimal totalOverpaidAmount, final ExternalTransferSubStatus expectedSubStatus) { + // given + TestContext testContext = new TestContext(); + + Loan loan = Mockito.mock(Loan.class); + when(loan.getTotalOverpaid()).thenReturn(totalOverpaidAmount); + + // when + ExternalTransferSubStatus result = testContext.testSubject.getDeclinedSubStatus(loan); + + // then + assertEquals(expectedSubStatus, result); + } + + private static class TestContext { + + @Mock + private DelayedSettlementAttributeService delayedSettlementAttributeService; + + @InjectMocks + private LoanTransferabilityServiceImpl testSubject; + + TestContext() { + MockitoAnnotations.openMocks(this); + } + } +} diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializerTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializerTest.java index 423e33ae1..19e5bdfee 100644 --- a/fineract-investor/src/test/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializerTest.java +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/serialization/serializer/investor/InvestorBusinessEventSerializerTest.java @@ -19,9 +19,13 @@ package org.apache.fineract.investor.service.serialization.serializer.investor; import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE; +import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK; +import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferStatus.CANCELLED; import static org.apache.fineract.investor.data.ExternalTransferStatus.DECLINED; +import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING; +import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING_INTERMEDIATE; import static org.apache.fineract.investor.data.ExternalTransferSubStatus.BALANCE_NEGATIVE; import static org.apache.fineract.investor.data.ExternalTransferSubStatus.BALANCE_ZERO; import static org.apache.fineract.investor.data.ExternalTransferSubStatus.SAMEDAY_TRANSFERS; @@ -67,35 +71,61 @@ public class InvestorBusinessEventSerializerTest { @Test public void testSerializationSellOK() { - doTest(ACTIVE, null, "SALE", "EXECUTED", null); + doTest(ACTIVE, null, null, "SALE", "EXECUTED", null); } @Test public void testSerializationBuybackOK() { - doTest(BUYBACK, null, "BUYBACK", "EXECUTED", null); + doTest(BUYBACK, null, null, "BUYBACK", "EXECUTED", null); + } + + @Test + public void testSerializationIntermediarySaleOK() { + doTest(ACTIVE_INTERMEDIATE, null, null, "INTERMEDIARYSALE", "EXECUTED", null); + } + + @Test + public void testSerializationBuybackIntermediateOK() { + doTest(BUYBACK_INTERMEDIATE, null, null, "BUYBACK", "EXECUTED", null); } @Test public void testSerializationDeclinedNegativeBalance() { - doTest(DECLINED, BALANCE_NEGATIVE, "SALE", "DECLINED", "BALANCE_NEGATIVE"); + doTest(DECLINED, BALANCE_NEGATIVE, PENDING, "SALE", "DECLINED", "BALANCE_NEGATIVE"); } @Test public void testSerializationDeclinedBalanceZero() { - doTest(DECLINED, BALANCE_ZERO, "SALE", "DECLINED", "BALANCE_ZERO"); + doTest(DECLINED, BALANCE_ZERO, PENDING, "SALE", "DECLINED", "BALANCE_ZERO"); + } + + @Test + public void testSerializationDeclinedPendingIntermediateNegativeBalance() { + doTest(DECLINED, BALANCE_NEGATIVE, PENDING_INTERMEDIATE, "INTERMEDIARYSALE", "DECLINED", "BALANCE_NEGATIVE"); + } + + @Test + public void testSerializationDeclinedPendingIntermediateBalanceZero() { + doTest(DECLINED, BALANCE_ZERO, PENDING_INTERMEDIATE, "INTERMEDIARYSALE", "DECLINED", "BALANCE_ZERO"); } @Test public void testSerializationCancelledSameDayTransfer() { - doTest(CANCELLED, SAMEDAY_TRANSFERS, "SALE", "CANCELLED", "SAMEDAY_TRANSFERS"); + doTest(CANCELLED, SAMEDAY_TRANSFERS, PENDING, "SALE", "CANCELLED", "SAMEDAY_TRANSFERS"); } - private void doTest(ExternalTransferStatus status, ExternalTransferSubStatus subStatus, String expectedType, String expectedStatus, - String expectedReason) { + @Test + public void testSerializationCancelledPendingIntermediateSameDayTransfer() { + doTest(CANCELLED, SAMEDAY_TRANSFERS, PENDING_INTERMEDIATE, "INTERMEDIARYSALE", "CANCELLED", "SAMEDAY_TRANSFERS"); + } + + private void doTest(ExternalTransferStatus status, ExternalTransferSubStatus subStatus, ExternalTransferStatus firstTransferStatus, + String expectedType, String expectedStatus, String expectedReason) { // given ExternalAssetOwnersReadService mockReadService = Mockito.mock(ExternalAssetOwnersReadService.class); when(mockReadService.retrieveTransferData(123L)).thenReturn(createTransferData(status, subStatus)); - when(mockReadService.retrieveFirstTransferByExternalId(any(ExternalId.class))).thenReturn(createTransferData(ACTIVE, null)); + when(mockReadService.retrieveFirstTransferByExternalId(any(ExternalId.class))) + .thenReturn(createTransferData(firstTransferStatus, null)); Loan loan = Mockito.mock(Loan.class); when(loan.getCurrency()).thenReturn(new MonetaryCurrency("EUR", 2, 1)); List loanCharges = createMockCharges(); diff --git a/fineract-loan/src/main/java/org/apache/fineract/cob/data/LoanIdAndExternalIdAndStatus.java b/fineract-loan/src/main/java/org/apache/fineract/cob/data/LoanDataForExternalTransfer.java similarity index 91% rename from fineract-loan/src/main/java/org/apache/fineract/cob/data/LoanIdAndExternalIdAndStatus.java rename to fineract-loan/src/main/java/org/apache/fineract/cob/data/LoanDataForExternalTransfer.java index 92c272939..e443d4065 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/cob/data/LoanIdAndExternalIdAndStatus.java +++ b/fineract-loan/src/main/java/org/apache/fineract/cob/data/LoanDataForExternalTransfer.java @@ -24,9 +24,11 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId; @AllArgsConstructor @Getter -public class LoanIdAndExternalIdAndStatus { +public class LoanDataForExternalTransfer { Long id; ExternalId externalId; Integer loanStatus; + Long loanProductId; + String loanProductShortName; } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java index b022596b8..128ffe153 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepository.java @@ -22,8 +22,8 @@ import java.time.LocalDate; import java.util.Collection; import java.util.List; import java.util.Optional; +import org.apache.fineract.cob.data.LoanDataForExternalTransfer; import org.apache.fineract.cob.data.LoanIdAndExternalIdAndAccountNo; -import org.apache.fineract.cob.data.LoanIdAndExternalIdAndStatus; import org.apache.fineract.cob.data.LoanIdAndLastClosedBusinessDate; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.springframework.data.jpa.repository.JpaRepository; @@ -80,7 +80,7 @@ public interface LoanRepository extends JpaRepository, JpaSpecificat String FIND_BY_ACCOUNT_NUMBER = "select loan from Loan loan where loan.accountNumber = :accountNumber"; - String FIND_LOAN_ID_AND_EXTERNAL_ID_AND_STATUS = "select new org.apache.fineract.cob.data.LoanIdAndExternalIdAndStatus(loan.id, loan.externalId, loan.loanStatus) from Loan loan where loan.id = :loanId"; + String FIND_LOAN_DATA_FOR_EXTERNAL_TRANSFER = "select new org.apache.fineract.cob.data.LoanDataForExternalTransfer(loan.id, loan.externalId, loan.loanStatus, loan.loanProduct.id, loan.loanProduct.shortName) from Loan loan where loan.id = :loanId"; String EXISTS_NON_CLOSED_BY_EXTERNAL_LOAN_ID = "select case when (count (loan) > 0) then 'true' else 'false' end from Loan loan where loan.externalId = :externalLoanId and loan.loanStatus in (100,200,300,303,304)"; String FIND_ID_BY_EXTERNAL_ID = "SELECT loan.id FROM Loan loan WHERE loan.externalId = :externalId"; @@ -205,8 +205,8 @@ public interface LoanRepository extends JpaRepository, JpaSpecificat @Query(FIND_BY_ACCOUNT_NUMBER) Loan findLoanAccountByAccountNumber(@Param("accountNumber") String accountNumber); - @Query(FIND_LOAN_ID_AND_EXTERNAL_ID_AND_STATUS) - Optional findLoanIdAndExternalIdAndStatusByLoanId(@Param("loanId") Long loanId); + @Query(FIND_LOAN_DATA_FOR_EXTERNAL_TRANSFER) + Optional findLoanDataForExternalTransferByLoanId(@Param("loanId") Long loanId); @Query(EXISTS_NON_CLOSED_BY_EXTERNAL_LOAN_ID) boolean existsNonClosedLoanByExternalLoanId(@Param("externalLoanId") ExternalId externalLoanId); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 8dba3f1e1..439d1d6fc 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -186,4 +186,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0168_transaction_summary_with_asset_owner_report_add_active_intermediate_filtering.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0168_transaction_summary_with_asset_owner_report_add_active_intermediate_filtering.xml new file mode 100644 index 000000000..e5387c2ac --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0168_transaction_summary_with_asset_owner_report_add_active_intermediate_filtering.xml @@ -0,0 +1,1120 @@ + + + + + + + report_name='Transaction Summary Report with Asset Owner' + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerTransferTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerTransferTest.java index ba1a9bea4..bdacddfdc 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerTransferTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerTransferTest.java @@ -123,15 +123,17 @@ public class ExternalAssetOwnerTransferTest extends BaseLoanIntegrationTest { protected PostInitiateTransferResponse createSaleTransfer(Integer loanID, String settlementDate) { String transferExternalId = UUID.randomUUID().toString(); + String transferExternalGroupId = UUID.randomUUID().toString(); ownerExternalId = UUID.randomUUID().toString(); - return createSaleTransfer(loanID, settlementDate, transferExternalId, ownerExternalId, "1.0"); + return createSaleTransfer(loanID, settlementDate, transferExternalId, transferExternalGroupId, ownerExternalId, "1.0"); } protected PostInitiateTransferResponse createSaleTransfer(Integer loanID, String settlementDate, String transferExternalId, - String ownerExternalId, String purchasePriceRatio) { + String transferExternalGroupId, String ownerExternalId, String purchasePriceRatio) { PostInitiateTransferResponse saleResponse = EXTERNAL_ASSET_OWNER_HELPER.initiateTransferByLoanId(loanID.longValue(), "sale", new PostInitiateTransferRequest().settlementDate(settlementDate).dateFormat("yyyy-MM-dd").locale("en") - .transferExternalId(transferExternalId).ownerExternalId(ownerExternalId).purchasePriceRatio(purchasePriceRatio)); + .transferExternalId(transferExternalId).transferExternalGroupId(transferExternalGroupId) + .ownerExternalId(ownerExternalId).purchasePriceRatio(purchasePriceRatio)); assertEquals(transferExternalId, saleResponse.getResourceExternalId()); return saleResponse; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java index 82d84e15d..f1e5a2c1c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java @@ -88,6 +88,7 @@ import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; import org.apache.fineract.integrationtests.common.loans.LoanStatusChecker; import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; import org.apache.fineract.integrationtests.common.report.ReportHelper; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; import org.hamcrest.Matchers; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Assertions; @@ -631,9 +632,12 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT LOAN_TRANSACTION_HELPER.makeRepayment("04 March 2020", 16000.0f, loanID); + HashMap loanStatusHashMap = LoanStatusChecker.getStatusOfLoan(REQUEST_SPEC, RESPONSE_SPEC, loanID); + LoanStatus loanStatus = LoanStatus.fromInt((Integer) loanStatusHashMap.get("id")); + CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, () -> createSaleTransfer(loanID, "2020-03-02")); - assertTrue(exception.getMessage().contains("Loan is not in active status")); + assertTrue(exception.getMessage().contains(String.format("Loan status %s is not valid for transfer.", loanStatus))); } finally { cleanUpAndRestoreBusinessDate(); } @@ -868,10 +872,12 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT assertTrue(exception5.getMessage().contains("Loan with identifier -1 does not exist")); String externalId = UUID.randomUUID().toString(); + String transferExternalGroupId = UUID.randomUUID().toString(); + CallFailedRuntimeException exception6 = assertThrows(CallFailedRuntimeException.class, () -> { Integer clientID = createClient(); Integer loanID = createLoanForClient(clientID); - createSaleTransfer(loanID, "2020-03-03", externalId, "1", "1.0"); + createSaleTransfer(loanID, "2020-03-03", externalId, transferExternalGroupId, "1", "1.0"); createBuybackTransfer(loanID, "2020-03-02", externalId); }); assertTrue(exception6.getMessage() @@ -892,12 +898,12 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, () -> createSaleTransfer(loanID, null)); assertTrue(exception.getMessage().contains("The parameter `settlementDate` is mandatory.")); - CallFailedRuntimeException exception2 = assertThrows(CallFailedRuntimeException.class, - () -> createSaleTransfer(loanID, "2020-03-02", UUID.randomUUID().toString(), null, "1.0")); + CallFailedRuntimeException exception2 = assertThrows(CallFailedRuntimeException.class, () -> createSaleTransfer(loanID, + "2020-03-02", UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, "1.0")); assertTrue(exception2.getMessage().contains("The parameter `ownerExternalId` is mandatory.")); CallFailedRuntimeException exception3 = assertThrows(CallFailedRuntimeException.class, - () -> createSaleTransfer(loanID, "2020-03-02", null, UUID.randomUUID().toString(), null)); + () -> createSaleTransfer(loanID, "2020-03-02", null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null)); assertTrue(exception3.getMessage().contains("The parameter `purchasePriceRatio` is mandatory.")); CallFailedRuntimeException exception4 = assertThrows(CallFailedRuntimeException.class, @@ -918,10 +924,11 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT }); assertTrue(exception6.getMessage().contains("This loan cannot be sold, because it is owned by an external asset owner")); String externalId = UUID.randomUUID().toString(); + String transferExternalGroupId = UUID.randomUUID().toString(); CallFailedRuntimeException exception7 = assertThrows(CallFailedRuntimeException.class, () -> { Integer loanID2 = createLoanForClient(clientID); - createSaleTransfer(loanID2, "2020-03-05", externalId, "1", "1.0"); - createSaleTransfer(loanID2, "2020-03-05", externalId, "1", "1.0"); + createSaleTransfer(loanID2, "2020-03-05", externalId, transferExternalGroupId, "1", "1.0"); + createSaleTransfer(loanID2, "2020-03-05", externalId, transferExternalGroupId, "1", "1.0"); }); assertTrue(exception7.getMessage() .contains(String.format("Already existing an asset transfer with the provided transfer external id: %s", externalId))); @@ -1178,15 +1185,17 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT private PostInitiateTransferResponse createSaleTransfer(Integer loanID, String settlementDate) { String transferExternalId = UUID.randomUUID().toString(); + String transferExternalGroupId = UUID.randomUUID().toString(); ownerExternalId = UUID.randomUUID().toString(); - return createSaleTransfer(loanID, settlementDate, transferExternalId, ownerExternalId, "1.0"); + return createSaleTransfer(loanID, settlementDate, transferExternalId, transferExternalGroupId, ownerExternalId, "1.0"); } private PostInitiateTransferResponse createSaleTransfer(Integer loanID, String settlementDate, String transferExternalId, - String ownerExternalId, String purchasePriceRatio) { + String transferExternalGroupId, String ownerExternalId, String purchasePriceRatio) { PostInitiateTransferResponse saleResponse = EXTERNAL_ASSET_OWNER_HELPER.initiateTransferByLoanId(loanID.longValue(), "sale", new PostInitiateTransferRequest().settlementDate(settlementDate).dateFormat("yyyy-MM-dd").locale("en") - .transferExternalId(transferExternalId).ownerExternalId(ownerExternalId).purchasePriceRatio(purchasePriceRatio)); + .transferExternalId(transferExternalId).transferExternalGroupId(transferExternalGroupId) + .ownerExternalId(ownerExternalId).purchasePriceRatio(purchasePriceRatio)); assertEquals(transferExternalId, saleResponse.getResourceExternalId()); return saleResponse; } @@ -1315,6 +1324,7 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT assertTrue(first.isPresent()); ExternalTransferData etd = first.get(); assertEquals(expected.transferExternalId, etd.getTransferExternalId()); + assertEquals(expected.status, etd.getStatus()); assertEquals(LocalDate.parse(expected.settlementDate), etd.getSettlementDate()); assertEquals(LocalDate.parse(expected.effectiveFrom), etd.getEffectiveFrom()); @@ -1415,6 +1425,7 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT private final String effectiveTo; private final ExternalTransferData.SubStatusEnum subStatus; private final boolean detailsExpected; + private final BigDecimal totalOutstanding; private final BigDecimal totalPrincipalOutstanding; private final BigDecimal totalInterestOutstanding;