Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ Feature: Asset Externalization - Part1
| sale | 2023-05-21 | 1 |

@TestRailId:C2735
Scenario: Verify that SALES request on a loan with ACTIVE ownership results an error
Scenario: Verify that SALES request on a loan with ACTIVE ownership succeeds (owner-to-owner transfer)
When Admin sets the business date to "1 May 2023"
When Admin creates a client with random data
When Admin creates a new default Loan with date: "1 May 2023"
Expand All @@ -304,9 +304,10 @@ Feature: Asset Externalization - Part1
| 2023-05-21 | 1 | PENDING | 2023-05-01 | 2023-05-21 | SALE |
| 2023-05-21 | 1 | ACTIVE | 2023-05-22 | 9999-12-31 | SALE |
When Admin sets the business date to "25 May 2023"
Then Asset externalization transaction with the following data results a 403 error and "ASSET_OWNED_CANNOT_BE_SOLD" error message
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
| Transaction type | settlementDate | purchasePriceRatio |
| sale | 2023-05-30 | 1 |
Then Asset externalization response has the correct Loan ID, transferExternalId

@TestRailId:C2736
Scenario: Verify that BUYBACK request on a fully paid loan can be done successfully
Expand Down Expand Up @@ -1811,3 +1812,66 @@ Feature: Asset Externalization - Part1
When Loan Pay-off is made on "26 June 2025"
Then Loan's all installments have obligations met

Scenario: Verify that when a loan with PENDING owner-to-owner SALES is fully paid asset transfer is DECLINED and original owner remains active
When Admin sets the business date to "1 May 2023"
When Admin creates a client with random data
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"
When Admin successfully disburse the loan on "1 May 2023" with "1000" EUR transaction amount
Then Loan status will be "ACTIVE"
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
| Transaction type | settlementDate | purchasePriceRatio |
| sale | 2023-05-21 | 1 |
Then Asset externalization response has the correct Loan ID, transferExternalId
When Admin sets the business date to "22 May 2023"
When Admin runs inline COB job for Loan
Then LoanOwnershipTransferBusinessEvent is created
Then LoanAccountSnapshotBusinessEvent is created
Then Fetching Asset externalization details by loan id gives numberOfElements: 2 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2023-05-21 | 1 | PENDING | 2023-05-01 | 2023-05-21 | SALE |
| 2023-05-21 | 1 | ACTIVE | 2023-05-22 | 9999-12-31 | SALE |
When Admin sets the business date to "25 May 2023"
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
| Transaction type | settlementDate | purchasePriceRatio |
| sale | 2023-06-14 | 1 |
Then Asset externalization response has the correct Loan ID, transferExternalId
When Admin sets the business date to "28 May 2023"
And Customer makes "AUTOPAY" repayment on "28 May 2023" with 1000 EUR transaction amount
Then Loan status will be "CLOSED_OBLIGATIONS_MET"
Then Fetching Asset externalization details by loan id gives numberOfElements: 4 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2023-06-14 | 1 | DECLINED | 2023-05-28 | 2023-05-28 | SALE |
Then LoanOwnershipTransferBusinessEvent with transfer status: "DECLINED" and transfer status reason "BALANCE_ZERO" is created

Scenario: Verify that when a loan with PENDING owner-to-owner SALES is overpaid asset transfer is DECLINED and original owner remains active
When Admin sets the business date to "1 May 2023"
When Admin creates a client with random data
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"
When Admin successfully disburse the loan on "1 May 2023" with "1000" EUR transaction amount
Then Loan status will be "ACTIVE"
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
| Transaction type | settlementDate | purchasePriceRatio |
| sale | 2023-05-21 | 1 |
Then Asset externalization response has the correct Loan ID, transferExternalId
When Admin sets the business date to "22 May 2023"
When Admin runs inline COB job for Loan
Then LoanOwnershipTransferBusinessEvent is created
Then LoanAccountSnapshotBusinessEvent is created
Then Fetching Asset externalization details by loan id gives numberOfElements: 2 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2023-05-21 | 1 | PENDING | 2023-05-01 | 2023-05-21 | SALE |
| 2023-05-21 | 1 | ACTIVE | 2023-05-22 | 9999-12-31 | SALE |
When Admin sets the business date to "25 May 2023"
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
| Transaction type | settlementDate | purchasePriceRatio |
| sale | 2023-06-14 | 1 |
Then Asset externalization response has the correct Loan ID, transferExternalId
When Admin sets the business date to "28 May 2023"
And Customer makes "AUTOPAY" repayment on "28 May 2023" with 1200 EUR transaction amount
Then Loan status will be "OVERPAID"
Then Fetching Asset externalization details by loan id gives numberOfElements: 4 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2023-06-14 | 1 | DECLINED | 2023-05-28 | 2023-05-28 | SALE |
Then LoanOwnershipTransferBusinessEvent with transfer status: "DECLINED" and transfer status reason "BALANCE_NEGATIVE" is created
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster;
import org.springframework.context.annotation.Conditional;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

@Component
Expand Down Expand Up @@ -173,21 +174,20 @@ private ExternalAssetOwnerTransfer sellAsset(final Loan loan, final LocalDate se
ExternalAssetOwner previousOwner = determinePreviousOwnerAndCleanupIfNeeded(loan, settlementDate, externalAssetOwnerTransfer);
ExternalTransferStatus activeStatus = determineActiveStatus(externalAssetOwnerTransfer);

ExternalAssetOwnerTransfer newTransfer = activatePendingEntry(settlementDate, externalAssetOwnerTransfer, activeStatus);
ExternalAssetOwnerTransfer newTransfer = activatePendingEntry(settlementDate, externalAssetOwnerTransfer, activeStatus,
previousOwner);

loanJournalEntryPoster.postJournalEntriesForExternalOwnerTransfer(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;
if (!delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId())
|| ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) {
// Use the loan mapping as the source of truth for the current owner.
// If a mapping exists, this is an owner-to-owner transfer — expire the current active and clean up.
return expireCurrentOwnerIfPresent(loan, settlementDate);
}

// When delayed settlement is enabled and asset is sold from intermediate to investor, the intermediate is the
Expand All @@ -199,6 +199,19 @@ private ExternalAssetOwner determinePreviousOwnerAndCleanupIfNeeded(final Loan l
return activeIntermediateTransfer.getOwner();
}

@Nullable
private ExternalAssetOwner expireCurrentOwnerIfPresent(final Loan loan, final LocalDate settlementDate) {
Optional<ExternalAssetOwnerTransfer> activeTransfer = externalAssetOwnerTransferRepository.findActiveByLoanId(loan.getId());
if (activeTransfer.isPresent()) {
ExternalAssetOwnerTransfer currentActiveTransfer = activeTransfer.get();
expireTransfer(settlementDate, currentActiveTransfer);
externalAssetOwnerTransferLoanMappingRepository.deleteByLoanIdAndOwnerTransfer(loan.getId(), currentActiveTransfer);
return currentActiveTransfer.getOwner();
}
// Internal-to-external transfer: no previous external owner
return null;
}

private ExternalTransferStatus determineActiveStatus(final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) {
if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) {
return ExternalTransferStatus.ACTIVE_INTERMEDIATE;
Expand All @@ -208,13 +221,16 @@ private ExternalTransferStatus determineActiveStatus(final ExternalAssetOwnerTra
}

private ExternalAssetOwnerTransfer getActiveIntermediateOrThrow(final Loan loan) {
Optional<ExternalAssetOwnerTransfer> optionalActiveIntermediateTransfer = externalAssetOwnerTransferRepository
Optional<ExternalAssetOwnerTransfer> optionalActiveIntermediateTransfer = findActiveIntermediateTransfer(loan);
return optionalActiveIntermediateTransfer
.orElseThrow(() -> new IllegalStateException("Expected a effective transfer of ACTIVE_INTERMEDIATE status to be present."));
}

private Optional<ExternalAssetOwnerTransfer> findActiveIntermediateTransfer(final Loan loan) {
return 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,
Expand Down Expand Up @@ -253,9 +269,11 @@ private ExternalAssetOwnerTransfer cancelTransfer(final LocalDate settlementDate
}

private ExternalAssetOwnerTransfer activatePendingEntry(final LocalDate settlementDate,
final ExternalAssetOwnerTransfer pendingTransfer, final ExternalTransferStatus activeStatus) {
final ExternalAssetOwnerTransfer pendingTransfer, final ExternalTransferStatus activeStatus,
final ExternalAssetOwner previousOwner) {
LocalDate effectiveFrom = settlementDate.plusDays(1);
return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, activeStatus, null, effectiveFrom, FUTURE_DATE_9999_12_31);
return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, activeStatus, null, effectiveFrom, FUTURE_DATE_9999_12_31,
previousOwner);
}

private ExternalAssetOwnerTransfer declinePendingEntry(final Loan loan, final LocalDate settlementDate,
Expand All @@ -267,6 +285,14 @@ private ExternalAssetOwnerTransfer declinePendingEntry(final Loan loan, final Lo
private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDate settlementDate,
final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, final ExternalTransferStatus status,
final ExternalTransferSubStatus subStatus, final LocalDate effectiveDateFrom, final LocalDate effectiveDateTo) {
return createNewEntryAndExpireOldEntry(settlementDate, externalAssetOwnerTransfer, status, subStatus, effectiveDateFrom,
effectiveDateTo, null);
}

private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDate settlementDate,
final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, final ExternalTransferStatus status,
final ExternalTransferSubStatus subStatus, final LocalDate effectiveDateFrom, final LocalDate effectiveDateTo,
final ExternalAssetOwner previousOwner) {
ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer = new ExternalAssetOwnerTransfer();
newExternalAssetOwnerTransfer.setOwner(externalAssetOwnerTransfer.getOwner());
newExternalAssetOwnerTransfer.setExternalId(externalAssetOwnerTransfer.getExternalId());
Expand All @@ -279,7 +305,8 @@ private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDa
newExternalAssetOwnerTransfer.setPurchasePriceRatio(externalAssetOwnerTransfer.getPurchasePriceRatio());
newExternalAssetOwnerTransfer.setEffectiveDateFrom(effectiveDateFrom);
newExternalAssetOwnerTransfer.setEffectiveDateTo(effectiveDateTo);
newExternalAssetOwnerTransfer.setPreviousOwner(externalAssetOwnerTransfer.getPreviousOwner());
newExternalAssetOwnerTransfer
.setPreviousOwner(previousOwner != null ? previousOwner : externalAssetOwnerTransfer.getPreviousOwner());

expireTransfer(settlementDate, externalAssetOwnerTransfer);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,16 +178,15 @@ private void validateEffectiveTransferForSale(final List<ExternalAssetOwnerTrans
if (effectiveTransfers.size() == 2) {
throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer");
} else if (effectiveTransfers.size() == 1) {
if (PENDING.equals(effectiveTransfers.getFirst().getStatus())) {
ExternalAssetOwnerTransfer transfer = effectiveTransfers.getFirst();
ExternalTransferStatus transferStatus = transfer.getStatus();
if (PENDING.equals(transferStatus)) {
throw new ExternalAssetOwnerInitiateTransferException(
"External asset owner transfer is already in PENDING state for this loan");
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus())) {
throw new ExternalAssetOwnerInitiateTransferException(
"This loan cannot be sold, because it is owned by an external asset owner");
} else {
}
if (!ExternalTransferStatus.ACTIVE.equals(transferStatus)) {
throw new ExternalAssetOwnerInitiateTransferException(
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)",
effectiveTransfers.getFirst().getId()));
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)", transfer.getId()));
}
}
}
Expand All @@ -212,17 +211,18 @@ private void validateEffectiveTransferForIntermediarySale(final ExternalAssetOwn
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.getFirst().getStatus())) {
ExternalAssetOwnerTransfer transfer = effectiveTransfers.getFirst();
ExternalTransferStatus transferStatus = transfer.getStatus();
if (PENDING_INTERMEDIATE.equals(transferStatus)) {
throw new ExternalAssetOwnerInitiateTransferException(
"External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan");
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus())) {
throw new ExternalAssetOwnerInitiateTransferException(
"This loan cannot be sold, because it is owned by an external asset owner");
} else {
}
if (!ExternalTransferStatus.ACTIVE.equals(transferStatus)) {
throw new ExternalAssetOwnerInitiateTransferException(
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)",
effectiveTransfers.getFirst().getId()));
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)", transfer.getId()));
}
// Owner-to-owner transfer with delayed settlement: allow intermediarySale when loan is currently
// owned. The actual ownership switch happens atomically in the COB step.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,8 @@ public void testSaleLoanWithDelayedSettlementFromIntermediateToInvestor() {

ArgumentCaptor<ExternalAssetOwnerTransfer> externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor
.forClass(ExternalAssetOwnerTransfer.class);
// 3 saves: activeIntermediateTransfer (expire), pendingTransfer (expire), activeTransfer (new, with previous
// owner set)
verify(externalAssetOwnerTransferRepository, times(3)).save(externalAssetOwnerTransferArgumentCaptor.capture());
ExternalAssetOwnerTransfer capturedActiveIntermediateTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0);
ExternalAssetOwnerTransfer capturedPendingTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1);
Expand Down
Loading