From 76d108953bcc4e696d0dbbfaf33857ef878dfb07 Mon Sep 17 00:00:00 2001 From: Ralph Hopman Date: Thu, 19 Feb 2026 18:18:35 +0100 Subject: [PATCH] FINERACT-2498: Add GROUP as a Guarantor Type to Support Group Savings Accounts as Loan Collateral --- .../guarantor/domain/GuarantorType.java | 7 +- .../TemplatePopulateImportConstants.java | 1 + .../guarantor/GuarantorImportHandler.java | 2 + .../guarantor/GuarantorWorkbookPopulator.java | 5 +- .../guarantor/data/GuarantorData.java | 4 + .../guarantor/domain/Guarantor.java | 4 + .../service/GuarantorDomainServiceImpl.java | 2 +- ...ritePlatformServiceJpaRepositoryIImpl.java | 22 +- .../GroupSavingsIntegrationTest.java | 318 +++++++++++++++++- .../guarantor/GuarantorHelper.java | 19 ++ .../guarantor/GuarantorTestBuilder.java | 12 +- 11 files changed, 387 insertions(+), 9 deletions(-) diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/GuarantorType.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/GuarantorType.java index 831f39fc1..966274a4c 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/GuarantorType.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/GuarantorType.java @@ -25,7 +25,8 @@ public enum GuarantorType { CUSTOMER(1, "guarantor.existing.customer"), // STAFF(2, "guarantor.staff"), // - EXTERNAL(3, "guarantor.external"); // + EXTERNAL(3, "guarantor.external"), // + GROUP(4, "guarantor.existing.group"); // private final Integer value; private final String code; @@ -90,4 +91,8 @@ public enum GuarantorType { return this.value.equals(GuarantorType.STAFF.getValue()); } + public boolean isGroup() { + return this.value.equals(GuarantorType.GROUP.getValue()); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/constants/TemplatePopulateImportConstants.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/constants/TemplatePopulateImportConstants.java index a93f67407..c7226e1c7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/constants/TemplatePopulateImportConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/constants/TemplatePopulateImportConstants.java @@ -141,6 +141,7 @@ public final class TemplatePopulateImportConstants { // Guarantor Types public static final String GUARANTOR_INTERNAL = "Internal"; public static final String GUARANTOR_EXTERNAL = "External"; + public static final String GUARANTOR_GROUP = "Group"; // Loan Account/Loan repayment Client External Id public static final Boolean CONTAINS_CLIENT_EXTERNAL_ID = true; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/guarantor/GuarantorImportHandler.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/guarantor/GuarantorImportHandler.java index e8b8c8c8b..c51be9c63 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/guarantor/GuarantorImportHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/guarantor/GuarantorImportHandler.java @@ -94,6 +94,8 @@ public class GuarantorImportHandler implements ImportHandler { guarantorTypeId = 1; } else if (guarantorType.equalsIgnoreCase(TemplatePopulateImportConstants.GUARANTOR_EXTERNAL)) { guarantorTypeId = 3; + } else if (guarantorType.equalsIgnoreCase(TemplatePopulateImportConstants.GUARANTOR_GROUP)) { + guarantorTypeId = 4; } } String clientName = ImportHandlerUtils.readAsString(GuarantorConstants.ENTITY_ID_COL, row); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/guarantor/GuarantorWorkbookPopulator.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/guarantor/GuarantorWorkbookPopulator.java index 984bee37d..ab9326b68 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/guarantor/GuarantorWorkbookPopulator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/populator/guarantor/GuarantorWorkbookPopulator.java @@ -209,8 +209,9 @@ public class GuarantorWorkbookPopulator extends AbstractWorkbookPopulator { "INDIRECT(CONCATENATE(\"Account_\",SUBSTITUTE(SUBSTITUTE(SUBSTITUTE($B1,\" \",\"_\"),\"(\",\"_\"),\")\",\"_\")))"); DataValidationConstraint savingsaccountNumberConstraint = validationHelper.createFormulaListConstraint( "INDIRECT(CONCATENATE(\"SavingsAccount_\",SUBSTITUTE(SUBSTITUTE(SUBSTITUTE($G1,\" \",\"_\"),\"(\",\"_\"),\")\",\"_\")))"); - DataValidationConstraint guranterTypeConstraint = validationHelper.createExplicitListConstraint( - new String[] { TemplatePopulateImportConstants.GUARANTOR_INTERNAL, TemplatePopulateImportConstants.GUARANTOR_EXTERNAL }); + DataValidationConstraint guranterTypeConstraint = validationHelper + .createExplicitListConstraint(new String[] { TemplatePopulateImportConstants.GUARANTOR_INTERNAL, + TemplatePopulateImportConstants.GUARANTOR_EXTERNAL, TemplatePopulateImportConstants.GUARANTOR_GROUP }); DataValidationConstraint guarantorRelationshipConstraint = validationHelper.createFormulaListConstraint("GuarantorRelationship"); DataValidationConstraint entityofficeNameConstraint = validationHelper.createFormulaListConstraint("Office"); DataValidationConstraint entityclientNameConstraint = validationHelper diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/data/GuarantorData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/data/GuarantorData.java index 4bdd36bea..0aa9a33bd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/data/GuarantorData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/data/GuarantorData.java @@ -207,4 +207,8 @@ public class GuarantorData implements IGuarantor { public boolean isStaffMember() { return GuarantorType.STAFF.getValue().equals(this.guarantorType.getId().intValue()); } + + public boolean isExistingGroup() { + return GuarantorType.GROUP.getValue().equals(this.guarantorType.getId().intValue()); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/Guarantor.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/Guarantor.java index 8b734bb76..7f7906f0f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/Guarantor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/domain/Guarantor.java @@ -188,6 +188,10 @@ public class Guarantor extends AbstractPersistableCustom { return GuarantorType.STAFF.getValue().equals(this.gurantorType); } + public boolean isExistingGroup() { + return GuarantorType.GROUP.getValue().equals(this.gurantorType); + } + public boolean isExternalGuarantor() { return GuarantorType.EXTERNAL.getValue().equals(this.gurantorType); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorDomainServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorDomainServiceImpl.java index 4113a842d..82d532ad7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorDomainServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorDomainServiceImpl.java @@ -390,7 +390,7 @@ public class GuarantorDomainServiceImpl implements GuarantorDomainService { if (guarantor.isSelfGuarantee()) { selfGuarantorList.add(guarantorFundingDetails); selfGuarantee = selfGuarantee.add(guarantorFundingDetails.getAmountRemaining()); - } else if (guarantor.isExistingCustomer()) { + } else if (guarantor.isExistingCustomer() || guarantor.isExistingGroup()) { externalGuarantorList.add(guarantorFundingDetails); guarantorGuarantee = guarantorGuarantee.add(guarantorFundingDetails.getAmountRemaining()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java index 7e71bd14c..9773a0e57 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java @@ -38,6 +38,7 @@ import org.apache.fineract.portfolio.account.domain.AccountAssociationType; import org.apache.fineract.portfolio.account.domain.AccountAssociations; import org.apache.fineract.portfolio.account.domain.AccountAssociationsRepository; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.group.domain.GroupRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.guarantor.GuarantorConstants; @@ -70,6 +71,7 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto private final ClientRepositoryWrapper clientRepositoryWrapper; private final StaffRepositoryWrapper staffRepositoryWrapper; + private final GroupRepositoryWrapper groupRepositoryWrapper; private final LoanRepositoryWrapper loanRepositoryWrapper; private final GuarantorRepository guarantorRepository; private final GuarantorCommandFromApiJsonDeserializer fromApiJsonDeserializer; @@ -81,11 +83,13 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto @Autowired public GuarantorWritePlatformServiceJpaRepositoryIImpl(final LoanRepositoryWrapper loanRepositoryWrapper, final GuarantorRepository guarantorRepository, final ClientRepositoryWrapper clientRepositoryWrapper, - final StaffRepositoryWrapper staffRepositoryWrapper, final GuarantorCommandFromApiJsonDeserializer fromApiJsonDeserializer, + final StaffRepositoryWrapper staffRepositoryWrapper, final GroupRepositoryWrapper groupRepositoryWrapper, + final GuarantorCommandFromApiJsonDeserializer fromApiJsonDeserializer, final CodeValueRepositoryWrapper codeValueRepositoryWrapper, final SavingsAccountAssembler savingsAccountAssembler, final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService) { this.loanRepositoryWrapper = loanRepositoryWrapper; this.clientRepositoryWrapper = clientRepositoryWrapper; + this.groupRepositoryWrapper = groupRepositoryWrapper; this.fromApiJsonDeserializer = fromApiJsonDeserializer; this.guarantorRepository = guarantorRepository; this.staffRepositoryWrapper = staffRepositoryWrapper; @@ -148,6 +152,8 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto String defaultUserMessage = null; if (guarantorTypeId.equals(GuarantorType.STAFF.getValue())) { defaultUserMessage = this.staffRepositoryWrapper.findOneWithNotFoundDetection(entityId).displayName(); + } else if (guarantorTypeId.equals(GuarantorType.GROUP.getValue())) { + defaultUserMessage = this.groupRepositoryWrapper.findOneWithNotFoundDetection(entityId).getName(); } else { defaultUserMessage = this.clientRepositoryWrapper.findOneWithNotFoundDetection(entityId).getDisplayName(); } @@ -225,12 +231,19 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto final List existGuarantorList = this.guarantorRepository.findByLoan(loan); final Integer guarantorTypeId = guarantorCommand.getGuarantorTypeId(); final GuarantorType guarantorType = GuarantorType.fromInt(guarantorTypeId); - if (guarantorType.isCustomer() || guarantorType.isStaff()) { + if (guarantorType.isCustomer() || guarantorType.isStaff() || guarantorType.isGroup()) { final Long entityId = guarantorCommand.getEntityId(); for (final Guarantor guarantor : existGuarantorList) { if (guarantor.getEntityId().equals(entityId) && guarantor.getGurantorType().equals(guarantorTypeId) && !guarantorForUpdate.getId().equals(guarantor.getId())) { - String defaultUserMessage = this.clientRepositoryWrapper.findOneWithNotFoundDetection(entityId).getDisplayName(); + String defaultUserMessage = null; + if (guarantorTypeId.equals(GuarantorType.STAFF.getValue())) { + defaultUserMessage = this.staffRepositoryWrapper.findOneWithNotFoundDetection(entityId).displayName(); + } else if (guarantorTypeId.equals(GuarantorType.GROUP.getValue())) { + defaultUserMessage = this.groupRepositoryWrapper.findOneWithNotFoundDetection(entityId).getName(); + } else { + defaultUserMessage = this.clientRepositoryWrapper.findOneWithNotFoundDetection(entityId).getDisplayName(); + } defaultUserMessage = defaultUserMessage + " is already exist as a guarantor for this loan"; final String action = loan.client() != null ? "client.guarantor" : "group.guarantor"; throw new DuplicateGuarantorException(action, "is.already.exist.same.loan", defaultUserMessage, entityId, loanId); @@ -336,6 +349,9 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto } else if (guarantor.isExistingEmployee()) { this.staffRepositoryWrapper.findOneWithNotFoundDetection(guarantor.getEntityId()); + } else if (guarantor.isExistingGroup()) { + // check group exists + this.groupRepositoryWrapper.findOneWithNotFoundDetection(guarantor.getEntityId()); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java index 2c6424bf5..07999a1cf 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java @@ -957,7 +957,7 @@ public class GroupSavingsIntegrationTest { // Add the group savings account as guarantor collateral for the loan // Use GUARANTEE_AMOUNT (500) as guarantee amount (less than the MINIMUM_OPENING_BALANCE of 1000) String guarantorJSON = new GuarantorTestBuilder() - .existingCustomerWithGuaranteeAmount(String.valueOf(groupID), String.valueOf(savingsId), GUARANTEE_AMOUNT).build(); + .existingGroupWithGuaranteeAmount(String.valueOf(groupID), String.valueOf(savingsId), GUARANTEE_AMOUNT).build(); LOG.info("Guarantor JSON: {}", guarantorJSON); LOG.info("Loan ID: {}, Group ID: {}, Savings ID: {}", loanID, groupID, savingsId); @@ -1023,6 +1023,322 @@ public class GroupSavingsIntegrationTest { "Should find at least one on-hold transaction with savingsClientName populated (group name)"); } + /** + * Test that creating a group guarantor with an invalid group ID fails with appropriate error + */ + @Test + public void testGroupGuarantorWithInvalidGroupId() { + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + + // Create a client who will take out the loan + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(clientID); + + // Create loan product with hold funds + LoanProductTestBuilder loanProductBuilder = new LoanProductTestBuilder().withPrincipal(PRINCIPAL).withNumberOfRepayments("4") + .withRepaymentAfterEvery("1").withRepaymentTypeAsWeek().withinterestRatePerPeriod("2") + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsDecliningBalance() + .withOnHoldFundDetails("0", "0", "0"); + final Integer loanProductID = this.loanTransactionHelper.getLoanProductId(loanProductBuilder.build(null)); + Assertions.assertNotNull(loanProductID); + + // Apply for a loan + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal(PRINCIPAL).withLoanTermFrequency("4") + .withLoanTermFrequencyAsWeeks().withNumberOfRepayments("4").withRepaymentEveryAfter("1").withRepaymentFrequencyTypeAsWeeks() + .withInterestRatePerPeriod("2").withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withSubmittedOnDate(SavingsAccountHelper.TRANSACTION_DATE) + .withExpectedDisbursementDate(SavingsAccountHelper.TRANSACTION_DATE) + .build(clientID.toString(), loanProductID.toString(), null); + final Integer loanID = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + Assertions.assertNotNull(loanID); + + // Try to create guarantor with invalid group ID (9999999) + final Integer invalidGroupId = 9999999; + String guarantorJSON = new GuarantorTestBuilder() + .existingGroupWithGuaranteeAmount(String.valueOf(invalidGroupId), "1", GUARANTEE_AMOUNT).build(); + + final ResponseSpecification errorResponse = new ResponseSpecBuilder().build(); + final RequestSpecification errorRequest = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + errorRequest.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + + ArrayList error = (ArrayList) this.guarantorHelper.createGuarantorWithError(loanID, guarantorJSON, errorRequest, + errorResponse); + // Verify we got an error response (status code may be 403 or 404 depending on environment) + Assertions.assertNotNull(error, "Should return error for invalid group ID"); + + LOG.info("SUCCESS: Invalid group ID correctly rejected"); + } + + /** + * Test that using a client ID with GROUP guarantor type fails with appropriate error + */ + @Test + public void testGroupGuarantorWithClientIdButGroupType() { + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + + // Create TWO clients - one for loan, one to misuse as "group" + final Integer loanClientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(loanClientID); + + final Integer otherClientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(otherClientID); + + // Create savings account for the other client + final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE, null, null, + "false"); + final Integer clientSavingsId = this.savingsAccountHelper.applyForSavingsApplication(otherClientID, savingsProductID, "INDIVIDUAL"); + this.savingsAccountHelper.approveSavings(clientSavingsId); + this.savingsAccountHelper.activateSavings(clientSavingsId); + + // Create loan product + LoanProductTestBuilder loanProductBuilder = new LoanProductTestBuilder().withPrincipal(PRINCIPAL).withNumberOfRepayments("4") + .withRepaymentAfterEvery("1").withRepaymentTypeAsWeek().withinterestRatePerPeriod("2") + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsDecliningBalance() + .withOnHoldFundDetails("0", "0", "0"); + final Integer loanProductID = this.loanTransactionHelper.getLoanProductId(loanProductBuilder.build(null)); + + // Create loan + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal(PRINCIPAL).withLoanTermFrequency("4") + .withLoanTermFrequencyAsWeeks().withNumberOfRepayments("4").withRepaymentEveryAfter("1").withRepaymentFrequencyTypeAsWeeks() + .withInterestRatePerPeriod("2").withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withSubmittedOnDate(SavingsAccountHelper.TRANSACTION_DATE) + .withExpectedDisbursementDate(SavingsAccountHelper.TRANSACTION_DATE) + .build(loanClientID.toString(), loanProductID.toString(), null); + final Integer loanID = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + + // Try to create guarantor with CLIENT ID but GROUP type (type mismatch) + String guarantorJSON = new GuarantorTestBuilder() + .existingGroupWithGuaranteeAmount(String.valueOf(otherClientID), String.valueOf(clientSavingsId), GUARANTEE_AMOUNT).build(); + + final ResponseSpecification errorResponse = new ResponseSpecBuilder().build(); + final RequestSpecification errorRequest = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + errorRequest.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + + ArrayList error = (ArrayList) this.guarantorHelper.createGuarantorWithError(loanID, guarantorJSON, errorRequest, + errorResponse); + // Verify we got an error response (status code may be 403 or 404 depending on environment) + Assertions.assertNotNull(error, "Should return error for client ID used with GROUP type"); + + LOG.info("SUCCESS: Client ID with GROUP type correctly rejected"); + } + + /** + * Test that duplicate group guarantor detection works and shows proper error message with group name + */ + @Test + public void testDuplicateGroupGuarantor() { + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + + // Create client for loan + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(clientID); + + // Create group with savings account + final Integer groupID = GroupHelper.createGroup(this.requestSpec, this.responseSpec, true); + Assertions.assertNotNull(groupID); + + final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE, null, null, + "false"); + final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(groupID, savingsProductID, ACCOUNT_TYPE_GROUP); + this.savingsAccountHelper.approveSavings(savingsId); + this.savingsAccountHelper.activateSavings(savingsId); + + // Create loan + LoanProductTestBuilder loanProductBuilder = new LoanProductTestBuilder().withPrincipal(PRINCIPAL).withNumberOfRepayments("4") + .withRepaymentAfterEvery("1").withRepaymentTypeAsWeek().withinterestRatePerPeriod("2") + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsDecliningBalance() + .withOnHoldFundDetails("0", "0", "0"); + final Integer loanProductID = this.loanTransactionHelper.getLoanProductId(loanProductBuilder.build(null)); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal(PRINCIPAL).withLoanTermFrequency("4") + .withLoanTermFrequencyAsWeeks().withNumberOfRepayments("4").withRepaymentEveryAfter("1").withRepaymentFrequencyTypeAsWeeks() + .withInterestRatePerPeriod("2").withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withSubmittedOnDate(SavingsAccountHelper.TRANSACTION_DATE) + .withExpectedDisbursementDate(SavingsAccountHelper.TRANSACTION_DATE) + .build(clientID.toString(), loanProductID.toString(), null); + final Integer loanID = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + + // Add group guarantor first time - should succeed + String guarantorJSON = new GuarantorTestBuilder() + .existingGroupWithGuaranteeAmount(String.valueOf(groupID), String.valueOf(savingsId), GUARANTEE_AMOUNT).build(); + Integer guarantorId1 = this.guarantorHelper.createGuarantor(loanID, guarantorJSON); + Assertions.assertNotNull(guarantorId1, "First guarantor creation should succeed"); + + // Try to add the SAME group guarantor again - should fail with duplicate error + final ResponseSpecification errorResponse = new ResponseSpecBuilder().expectStatusCode(403).build(); + final RequestSpecification errorRequest = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + errorRequest.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + + ArrayList error = (ArrayList) this.guarantorHelper.createGuarantorWithError(loanID, guarantorJSON, errorRequest, + errorResponse); + Assertions.assertNotNull(error, "Should return error for duplicate group guarantor"); + + // Verify error message contains group information + HashMap errorData = error.get(0); + String userMessage = (String) errorData.get("userMessageGlobalisationCode"); + Assertions.assertTrue(userMessage != null && userMessage.contains("already.exist"), + "Error message should indicate duplicate guarantor"); + + LOG.info("SUCCESS: Duplicate group guarantor correctly rejected"); + } + + /** + * Test complete loan lifecycle (approval, disbursement, repayment) with a group guarantor + */ + @Test + public void testGroupGuarantorLoanLifecycle() { + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + + // Create client for loan + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(clientID); + + // Create group with savings account + final Integer groupID = GroupHelper.createGroup(this.requestSpec, this.responseSpec, true); + Assertions.assertNotNull(groupID); + + final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE, null, null, + "false"); + final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(groupID, savingsProductID, ACCOUNT_TYPE_GROUP); + this.savingsAccountHelper.approveSavings(savingsId); + this.savingsAccountHelper.activateSavings(savingsId); + + // Deposit funds into the savings account to cover the guarantee + this.savingsAccountHelper.depositToSavingsAccount(savingsId, DEPOSIT_AMOUNT, SavingsAccountHelper.TRANSACTION_DATE, + CommonConstants.RESPONSE_RESOURCE_ID); + + // Create loan product - using minimal hold fund requirements for testing + // Focus is on verifying that group guarantors work, not on complex hold fund logic + LoanProductTestBuilder loanProductBuilder = new LoanProductTestBuilder().withPrincipal("2000").withNumberOfRepayments("1") + .withRepaymentAfterEvery("1").withRepaymentTypeAsWeek().withinterestRatePerPeriod("0") + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsDecliningBalance() + .withOnHoldFundDetails("0", "0", "0"); + final Integer loanProductID = this.loanTransactionHelper.getLoanProductId(loanProductBuilder.build(null)); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("2000").withLoanTermFrequency("1") + .withLoanTermFrequencyAsWeeks().withNumberOfRepayments("1").withRepaymentEveryAfter("1").withRepaymentFrequencyTypeAsWeeks() + .withInterestRatePerPeriod("0").withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withSubmittedOnDate(SavingsAccountHelper.TRANSACTION_DATE) + .withExpectedDisbursementDate(SavingsAccountHelper.TRANSACTION_DATE) + .build(clientID.toString(), loanProductID.toString(), null); + final Integer loanID = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + + // Add group guarantor with amount = 1000 (50% of 2000 loan) + String guarantorJSON = new GuarantorTestBuilder() + .existingGroupWithGuaranteeAmount(String.valueOf(groupID), String.valueOf(savingsId), "1000").build(); + Integer guarantorId = this.guarantorHelper.createGuarantor(loanID, guarantorJSON); + Assertions.assertNotNull(guarantorId); + + // Verify guarantor was created successfully + ArrayList guarantors = this.guarantorHelper.getGuarantorList(loanID); + Assertions.assertEquals(1, guarantors.size(), "Should have 1 group guarantor"); + HashMap guarantor = guarantors.get(0); + HashMap guarantorType = (HashMap) guarantor.get("guarantorType"); + Assertions.assertEquals(4, guarantorType.get("id"), "Guarantor type should be GROUP (4)"); + + // Approve loan with group guarantor + HashMap loanStatusHashMap = this.loanTransactionHelper.approveLoan(SavingsAccountHelper.TRANSACTION_DATE, loanID); + Assertions.assertNotNull(loanStatusHashMap, "Loan approval should succeed with group guarantor"); + + // Disburse loan + this.loanTransactionHelper.disburseLoan(Long.valueOf(loanID), SavingsAccountHelper.TRANSACTION_DATE, 2000.0); + + // Make full repayment + final String repaymentDate = SavingsAccountHelper.TRANSACTION_DATE; + this.loanTransactionHelper.makeRepayment(repaymentDate, Float.parseFloat("2000"), loanID); + + LOG.info("SUCCESS: Group guarantor lifecycle test completed"); + } + + /** + * Test mixed client and group guarantors on the same loan + */ + @Test + public void testMixedClientAndGroupGuarantors() { + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + + // Create loan borrower client + final Integer borrowerClientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(borrowerClientID); + + // Create guarantor client with savings + final Integer guarantorClientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(guarantorClientID); + + // Create guarantor group with savings + final Integer guarantorGroupID = GroupHelper.createGroup(this.requestSpec, this.responseSpec, true); + Assertions.assertNotNull(guarantorGroupID); + + // Create savings accounts + final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE, null, null, + "false"); + + final Integer clientSavingsId = this.savingsAccountHelper.applyForSavingsApplication(guarantorClientID, savingsProductID, + "INDIVIDUAL"); + this.savingsAccountHelper.approveSavings(clientSavingsId); + this.savingsAccountHelper.activateSavings(clientSavingsId); + + final Integer groupSavingsId = this.savingsAccountHelper.applyForSavingsApplication(guarantorGroupID, savingsProductID, + ACCOUNT_TYPE_GROUP); + this.savingsAccountHelper.approveSavings(groupSavingsId); + this.savingsAccountHelper.activateSavings(groupSavingsId); + + // Create loan + LoanProductTestBuilder loanProductBuilder = new LoanProductTestBuilder().withPrincipal(PRINCIPAL).withNumberOfRepayments("4") + .withRepaymentAfterEvery("1").withRepaymentTypeAsWeek().withinterestRatePerPeriod("2") + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsDecliningBalance() + .withOnHoldFundDetails("0", "0", "0"); + final Integer loanProductID = this.loanTransactionHelper.getLoanProductId(loanProductBuilder.build(null)); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal(PRINCIPAL).withLoanTermFrequency("4") + .withLoanTermFrequencyAsWeeks().withNumberOfRepayments("4").withRepaymentEveryAfter("1").withRepaymentFrequencyTypeAsWeeks() + .withInterestRatePerPeriod("2").withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withSubmittedOnDate(SavingsAccountHelper.TRANSACTION_DATE) + .withExpectedDisbursementDate(SavingsAccountHelper.TRANSACTION_DATE) + .build(borrowerClientID.toString(), loanProductID.toString(), null); + final Integer loanID = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + + // Add CLIENT guarantor + String clientGuarantorJSON = new GuarantorTestBuilder() + .existingCustomerWithGuaranteeAmount(String.valueOf(guarantorClientID), String.valueOf(clientSavingsId), "250").build(); + Integer clientGuarantorId = this.guarantorHelper.createGuarantor(loanID, clientGuarantorJSON); + Assertions.assertNotNull(clientGuarantorId, "Client guarantor creation should succeed"); + + // Add GROUP guarantor + String groupGuarantorJSON = new GuarantorTestBuilder() + .existingGroupWithGuaranteeAmount(String.valueOf(guarantorGroupID), String.valueOf(groupSavingsId), "250").build(); + Integer groupGuarantorId = this.guarantorHelper.createGuarantor(loanID, groupGuarantorJSON); + Assertions.assertNotNull(groupGuarantorId, "Group guarantor creation should succeed"); + + // Retrieve all guarantors for the loan + ArrayList guarantors = this.guarantorHelper.getGuarantorList(loanID); + Assertions.assertNotNull(guarantors, "Should retrieve guarantor list"); + Assertions.assertEquals(2, guarantors.size(), "Should have 2 guarantors (1 client, 1 group)"); + + // Verify both guarantor types are present + boolean hasClientGuarantor = false; + boolean hasGroupGuarantor = false; + + for (HashMap guarantor : guarantors) { + HashMap guarantorType = (HashMap) guarantor.get("guarantorType"); + Integer typeId = (Integer) guarantorType.get("id"); + + if (typeId == 1) { // CUSTOMER/CLIENT + hasClientGuarantor = true; + } else if (typeId == 4) { // GROUP + hasGroupGuarantor = true; + } + } + + Assertions.assertTrue(hasClientGuarantor, "Should have client guarantor"); + Assertions.assertTrue(hasGroupGuarantor, "Should have group guarantor"); + + // Approve loan - both holds should be placed + this.loanTransactionHelper.approveLoan(SavingsAccountHelper.TRANSACTION_DATE, loanID); + + LOG.info("SUCCESS: Mixed client and group guarantors work together"); + } + @Test public void testGroupAccountAvailableBalance() { this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorHelper.java index 6ba5a231a..304233143 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorHelper.java @@ -51,6 +51,25 @@ public class GuarantorHelper { CommonConstants.RESPONSE_RESOURCE_ID); } + // TODO: Rewrite to use fineract-client instead! + // Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long, + // org.apache.fineract.client.models.PostLoansLoanIdRequest) + @Deprecated(forRemoval = true) + public Object createGuarantorWithError(final Integer loanId, final String guarantorJSON, final RequestSpecification requestSpec, + final ResponseSpecification responseSpec) { + return Utils.performServerPost(requestSpec, responseSpec, LOAN_URL + loanId + GUARANTOR_API_URL + TENANT, guarantorJSON, + CommonConstants.RESPONSE_ERROR); + } + + // TODO: Rewrite to use fineract-client instead! + // Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long, + // org.apache.fineract.client.models.PostLoansLoanIdRequest) + @Deprecated(forRemoval = true) + public java.util.ArrayList getGuarantorList(final Integer loanId) { + return (java.util.ArrayList) Utils.performServerGet(this.requestSpec, this.responseSpec, + LOAN_URL + loanId + GUARANTOR_API_URL + TENANT, ""); + } + // TODO: Rewrite to use fineract-client instead! // Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long, // org.apache.fineract.client.models.PostLoansLoanIdRequest) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorTestBuilder.java index 0b793373d..ef2e5707d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/guarantor/GuarantorTestBuilder.java @@ -31,6 +31,7 @@ public class GuarantorTestBuilder { @SuppressWarnings("unused") private static final String GUARANTOR_TYPE_STAFF = "2"; private static final String GUARANTOR_TYPE_EXTERNAL = "3"; + private static final String GUARANTOR_TYPE_GROUP = "4"; private String guarantorTypeId = "1"; private String entityId = null; @@ -55,7 +56,7 @@ public class GuarantorTestBuilder { map.put("state", state); map.put("zip", zip); - } else if (GUARANTOR_TYPE_CUSTOMER.equals(guarantorTypeId)) { + } else if (GUARANTOR_TYPE_CUSTOMER.equals(guarantorTypeId) || GUARANTOR_TYPE_GROUP.equals(guarantorTypeId)) { map.put("entityId", entityId); map.put("amount", guaranteeAmount); map.put("savingsId", savingsId); @@ -81,6 +82,15 @@ public class GuarantorTestBuilder { return this; } + public GuarantorTestBuilder existingGroupWithGuaranteeAmount(final String entityId, final String savingsId, + final String guaranteeAmount) { + this.entityId = entityId; + this.savingsId = savingsId; + this.guaranteeAmount = guaranteeAmount; + this.guarantorTypeId = GUARANTOR_TYPE_GROUP; + return this; + } + public GuarantorTestBuilder externalCustomer() { this.guarantorTypeId = GUARANTOR_TYPE_EXTERNAL; return this;