Skip to content

Commit babe420

Browse files
committed
FINERACT-2541: owner to owner transfer functionality
1 parent 2454f9d commit babe420

7 files changed

Lines changed: 414 additions & 44 deletions

File tree

fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization-Part1.feature

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ Feature: Asset Externalization - Part1
281281
| sale | 2023-05-21 | 1 |
282282

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

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

1815+
Scenario: Verify that when a loan with PENDING owner-to-owner SALES is fully paid asset transfer is DECLINED and original owner remains active
1816+
When Admin sets the business date to "1 May 2023"
1817+
When Admin creates a client with random data
1818+
When Admin creates a new default Loan with date: "1 May 2023"
1819+
And Admin successfully approves the loan on "1 May 2023" with "1000" amount and expected disbursement date on "1 May 2023"
1820+
When Admin successfully disburse the loan on "1 May 2023" with "1000" EUR transaction amount
1821+
Then Loan status will be "ACTIVE"
1822+
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
1823+
| Transaction type | settlementDate | purchasePriceRatio |
1824+
| sale | 2023-05-21 | 1 |
1825+
Then Asset externalization response has the correct Loan ID, transferExternalId
1826+
When Admin sets the business date to "22 May 2023"
1827+
When Admin runs inline COB job for Loan
1828+
Then LoanOwnershipTransferBusinessEvent is created
1829+
Then LoanAccountSnapshotBusinessEvent is created
1830+
Then Fetching Asset externalization details by loan id gives numberOfElements: 2 with correct ownerExternalId and the following data:
1831+
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
1832+
| 2023-05-21 | 1 | PENDING | 2023-05-01 | 2023-05-21 | SALE |
1833+
| 2023-05-21 | 1 | ACTIVE | 2023-05-22 | 9999-12-31 | SALE |
1834+
When Admin sets the business date to "25 May 2023"
1835+
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
1836+
| Transaction type | settlementDate | purchasePriceRatio |
1837+
| sale | 2023-06-14 | 1 |
1838+
Then Asset externalization response has the correct Loan ID, transferExternalId
1839+
When Admin sets the business date to "28 May 2023"
1840+
And Customer makes "AUTOPAY" repayment on "28 May 2023" with 1000 EUR transaction amount
1841+
Then Loan status will be "CLOSED_OBLIGATIONS_MET"
1842+
Then Fetching Asset externalization details by loan id gives numberOfElements: 4 with correct ownerExternalId and the following data:
1843+
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
1844+
| 2023-06-14 | 1 | DECLINED | 2023-05-28 | 2023-05-28 | SALE |
1845+
Then LoanOwnershipTransferBusinessEvent with transfer status: "DECLINED" and transfer status reason "BALANCE_ZERO" is created
1846+
1847+
Scenario: Verify that when a loan with PENDING owner-to-owner SALES is overpaid asset transfer is DECLINED and original owner remains active
1848+
When Admin sets the business date to "1 May 2023"
1849+
When Admin creates a client with random data
1850+
When Admin creates a new default Loan with date: "1 May 2023"
1851+
And Admin successfully approves the loan on "1 May 2023" with "1000" amount and expected disbursement date on "1 May 2023"
1852+
When Admin successfully disburse the loan on "1 May 2023" with "1000" EUR transaction amount
1853+
Then Loan status will be "ACTIVE"
1854+
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
1855+
| Transaction type | settlementDate | purchasePriceRatio |
1856+
| sale | 2023-05-21 | 1 |
1857+
Then Asset externalization response has the correct Loan ID, transferExternalId
1858+
When Admin sets the business date to "22 May 2023"
1859+
When Admin runs inline COB job for Loan
1860+
Then LoanOwnershipTransferBusinessEvent is created
1861+
Then LoanAccountSnapshotBusinessEvent is created
1862+
Then Fetching Asset externalization details by loan id gives numberOfElements: 2 with correct ownerExternalId and the following data:
1863+
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
1864+
| 2023-05-21 | 1 | PENDING | 2023-05-01 | 2023-05-21 | SALE |
1865+
| 2023-05-21 | 1 | ACTIVE | 2023-05-22 | 9999-12-31 | SALE |
1866+
When Admin sets the business date to "25 May 2023"
1867+
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
1868+
| Transaction type | settlementDate | purchasePriceRatio |
1869+
| sale | 2023-06-14 | 1 |
1870+
Then Asset externalization response has the correct Loan ID, transferExternalId
1871+
When Admin sets the business date to "28 May 2023"
1872+
And Customer makes "AUTOPAY" repayment on "28 May 2023" with 1200 EUR transaction amount
1873+
Then Loan status will be "OVERPAID"
1874+
Then Fetching Asset externalization details by loan id gives numberOfElements: 4 with correct ownerExternalId and the following data:
1875+
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
1876+
| 2023-06-14 | 1 | DECLINED | 2023-05-28 | 2023-05-28 | SALE |
1877+
Then LoanOwnershipTransferBusinessEvent with transfer status: "DECLINED" and transfer status reason "BALANCE_NEGATIVE" is created

fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster;
4747
import org.springframework.context.annotation.Conditional;
4848
import org.springframework.data.domain.Sort;
49+
import org.springframework.lang.Nullable;
4950
import org.springframework.stereotype.Component;
5051

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

176-
ExternalAssetOwnerTransfer newTransfer = activatePendingEntry(settlementDate, externalAssetOwnerTransfer, activeStatus);
177+
ExternalAssetOwnerTransfer newTransfer = activatePendingEntry(settlementDate, externalAssetOwnerTransfer, activeStatus,
178+
previousOwner);
179+
177180
loanJournalEntryPoster.postJournalEntriesForExternalOwnerTransfer(loan, newTransfer, previousOwner);
178181
return newTransfer;
179182
}
180183

181184
private ExternalAssetOwner determinePreviousOwnerAndCleanupIfNeeded(final Loan loan, final LocalDate settlementDate,
182185
final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) {
183-
if (!delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId())) {
184-
// When delayed settlement is disabled, asset is directly sold to investor, and we are the previous owner.
185-
return null;
186-
}
187-
188-
if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) {
189-
// When delayed settlement is enabled and asset is sold to intermediate, we are the previous owner.
190-
return null;
186+
if (!delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId())
187+
|| ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) {
188+
// Use the loan mapping as the source of truth for the current owner.
189+
// If a mapping exists, this is an owner-to-owner transfer — expire the current active and clean up.
190+
return expireCurrentOwnerIfPresent(loan, settlementDate);
191191
}
192192

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

202+
@Nullable
203+
private ExternalAssetOwner expireCurrentOwnerIfPresent(final Loan loan, final LocalDate settlementDate) {
204+
Optional<ExternalAssetOwnerTransfer> activeTransfer = externalAssetOwnerTransferRepository.findActiveByLoanId(loan.getId());
205+
if (activeTransfer.isPresent()) {
206+
ExternalAssetOwnerTransfer currentActiveTransfer = activeTransfer.get();
207+
expireTransfer(settlementDate, currentActiveTransfer);
208+
externalAssetOwnerTransferLoanMappingRepository.deleteByLoanIdAndOwnerTransfer(loan.getId(), currentActiveTransfer);
209+
return currentActiveTransfer.getOwner();
210+
}
211+
// Internal-to-external transfer: no previous external owner
212+
return null;
213+
}
214+
202215
private ExternalTransferStatus determineActiveStatus(final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) {
203216
if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) {
204217
return ExternalTransferStatus.ACTIVE_INTERMEDIATE;
@@ -208,13 +221,16 @@ private ExternalTransferStatus determineActiveStatus(final ExternalAssetOwnerTra
208221
}
209222

210223
private ExternalAssetOwnerTransfer getActiveIntermediateOrThrow(final Loan loan) {
211-
Optional<ExternalAssetOwnerTransfer> optionalActiveIntermediateTransfer = externalAssetOwnerTransferRepository
224+
Optional<ExternalAssetOwnerTransfer> optionalActiveIntermediateTransfer = findActiveIntermediateTransfer(loan);
225+
return optionalActiveIntermediateTransfer
226+
.orElseThrow(() -> new IllegalStateException("Expected a effective transfer of ACTIVE_INTERMEDIATE status to be present."));
227+
}
228+
229+
private Optional<ExternalAssetOwnerTransfer> findActiveIntermediateTransfer(final Loan loan) {
230+
return externalAssetOwnerTransferRepository
212231
.findOne((root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loan.getId()),
213232
criteriaBuilder.equal(root.get("status"), ExternalTransferStatus.ACTIVE_INTERMEDIATE),
214233
criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31)));
215-
216-
return optionalActiveIntermediateTransfer
217-
.orElseThrow(() -> new IllegalStateException("Expected a effective transfer of ACTIVE_INTERMEDIATE status to be present."));
218234
}
219235

220236
private ExternalAssetOwnerTransferDetails createAssetOwnerTransferDetails(Loan loan,
@@ -253,9 +269,11 @@ private ExternalAssetOwnerTransfer cancelTransfer(final LocalDate settlementDate
253269
}
254270

255271
private ExternalAssetOwnerTransfer activatePendingEntry(final LocalDate settlementDate,
256-
final ExternalAssetOwnerTransfer pendingTransfer, final ExternalTransferStatus activeStatus) {
272+
final ExternalAssetOwnerTransfer pendingTransfer, final ExternalTransferStatus activeStatus,
273+
final ExternalAssetOwner previousOwner) {
257274
LocalDate effectiveFrom = settlementDate.plusDays(1);
258-
return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, activeStatus, null, effectiveFrom, FUTURE_DATE_9999_12_31);
275+
return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, activeStatus, null, effectiveFrom, FUTURE_DATE_9999_12_31,
276+
previousOwner);
259277
}
260278

261279
private ExternalAssetOwnerTransfer declinePendingEntry(final Loan loan, final LocalDate settlementDate,
@@ -267,6 +285,14 @@ private ExternalAssetOwnerTransfer declinePendingEntry(final Loan loan, final Lo
267285
private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDate settlementDate,
268286
final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, final ExternalTransferStatus status,
269287
final ExternalTransferSubStatus subStatus, final LocalDate effectiveDateFrom, final LocalDate effectiveDateTo) {
288+
return createNewEntryAndExpireOldEntry(settlementDate, externalAssetOwnerTransfer, status, subStatus, effectiveDateFrom,
289+
effectiveDateTo, null);
290+
}
291+
292+
private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDate settlementDate,
293+
final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, final ExternalTransferStatus status,
294+
final ExternalTransferSubStatus subStatus, final LocalDate effectiveDateFrom, final LocalDate effectiveDateTo,
295+
final ExternalAssetOwner previousOwner) {
270296
ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer = new ExternalAssetOwnerTransfer();
271297
newExternalAssetOwnerTransfer.setOwner(externalAssetOwnerTransfer.getOwner());
272298
newExternalAssetOwnerTransfer.setExternalId(externalAssetOwnerTransfer.getExternalId());
@@ -279,7 +305,8 @@ private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDa
279305
newExternalAssetOwnerTransfer.setPurchasePriceRatio(externalAssetOwnerTransfer.getPurchasePriceRatio());
280306
newExternalAssetOwnerTransfer.setEffectiveDateFrom(effectiveDateFrom);
281307
newExternalAssetOwnerTransfer.setEffectiveDateTo(effectiveDateTo);
282-
newExternalAssetOwnerTransfer.setPreviousOwner(externalAssetOwnerTransfer.getPreviousOwner());
308+
newExternalAssetOwnerTransfer
309+
.setPreviousOwner(previousOwner != null ? previousOwner : externalAssetOwnerTransfer.getPreviousOwner());
283310

284311
expireTransfer(settlementDate, externalAssetOwnerTransfer);
285312

fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,15 @@ private void validateEffectiveTransferForSale(final List<ExternalAssetOwnerTrans
178178
if (effectiveTransfers.size() == 2) {
179179
throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer");
180180
} else if (effectiveTransfers.size() == 1) {
181-
if (PENDING.equals(effectiveTransfers.getFirst().getStatus())) {
181+
ExternalAssetOwnerTransfer transfer = effectiveTransfers.getFirst();
182+
ExternalTransferStatus transferStatus = transfer.getStatus();
183+
if (PENDING.equals(transferStatus)) {
182184
throw new ExternalAssetOwnerInitiateTransferException(
183185
"External asset owner transfer is already in PENDING state for this loan");
184-
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus())) {
185-
throw new ExternalAssetOwnerInitiateTransferException(
186-
"This loan cannot be sold, because it is owned by an external asset owner");
187-
} else {
186+
}
187+
if (!ExternalTransferStatus.ACTIVE.equals(transferStatus)) {
188188
throw new ExternalAssetOwnerInitiateTransferException(
189-
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)",
190-
effectiveTransfers.getFirst().getId()));
189+
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)", transfer.getId()));
191190
}
192191
}
193192
}
@@ -212,17 +211,18 @@ private void validateEffectiveTransferForIntermediarySale(final ExternalAssetOwn
212211
if (effectiveTransfers.size() > 1) {
213212
throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer");
214213
} else if (effectiveTransfers.size() == 1) {
215-
if (PENDING_INTERMEDIATE.equals(effectiveTransfers.getFirst().getStatus())) {
214+
ExternalAssetOwnerTransfer transfer = effectiveTransfers.getFirst();
215+
ExternalTransferStatus transferStatus = transfer.getStatus();
216+
if (PENDING_INTERMEDIATE.equals(transferStatus)) {
216217
throw new ExternalAssetOwnerInitiateTransferException(
217218
"External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan");
218-
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus())) {
219-
throw new ExternalAssetOwnerInitiateTransferException(
220-
"This loan cannot be sold, because it is owned by an external asset owner");
221-
} else {
219+
}
220+
if (!ExternalTransferStatus.ACTIVE.equals(transferStatus)) {
222221
throw new ExternalAssetOwnerInitiateTransferException(
223-
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)",
224-
effectiveTransfers.getFirst().getId()));
222+
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)", transfer.getId()));
225223
}
224+
// Owner-to-owner transfer with delayed settlement: allow intermediarySale when loan is currently
225+
// owned. The actual ownership switch happens atomically in the COB step.
226226
}
227227
}
228228

fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,8 @@ public void testSaleLoanWithDelayedSettlementFromIntermediateToInvestor() {
494494

495495
ArgumentCaptor<ExternalAssetOwnerTransfer> externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor
496496
.forClass(ExternalAssetOwnerTransfer.class);
497+
// 3 saves: activeIntermediateTransfer (expire), pendingTransfer (expire), activeTransfer (new, with previous
498+
// owner set)
497499
verify(externalAssetOwnerTransferRepository, times(3)).save(externalAssetOwnerTransferArgumentCaptor.capture());
498500
ExternalAssetOwnerTransfer capturedActiveIntermediateTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0);
499501
ExternalAssetOwnerTransfer capturedPendingTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1);

0 commit comments

Comments
 (0)