feat(clients): enforce Mother + Father/Caretaker family members on client activation
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

- Add CARETAKER as a new RELATIONSHIP code value (previously missing — a client with no
  father had no valid way to satisfy the rule below).
- Add ClientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(),
  called from activateClient(): requires an active "Mother" family member, plus either
  "Father" or "Caretaker", for individual clients (non-individual entities are exempt).
- Two distinct exceptions (ClientFamilyMemberMotherRequiredException /
  ClientFamilyMemberFatherOrCaretakerRequiredException) so the two failure reasons surface
  as separate errors instead of one merged message, mirroring the frontend's separate banners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 12:37:24 +05:30
co-authored by Claude Sonnet 5
parent 0e5671b706
commit aabda18b69
8 changed files with 175 additions and 0 deletions
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.client.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
@@ -52,4 +53,11 @@ public interface ClientFamilyMembersRepository
@Param("relationshipId") Long relationshipId,
@Param("excludeFamilyMemberId") Long excludeFamilyMemberId);
/**
* All non-deleted family members for a client, with their relationship CodeValue. Backs the
* "Mother required, plus Father or Caretaker" activation gate.
*/
@Query("SELECT fm FROM ClientFamilyMembers fm WHERE fm.client.id = :clientId AND fm.isDeleted = false")
List<ClientFamilyMembers> findAllActiveByClient(@Param("clientId") Long clientId);
}
@@ -0,0 +1,34 @@
/**
* 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.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown on client activation when neither an active "Father" nor "Caretaker" family member has
* been recorded. Kept separate from {@link ClientFamilyMemberMotherRequiredException} so the two
* failure reasons surface as distinct errors instead of one merged message.
*/
public class ClientFamilyMemberFatherOrCaretakerRequiredException extends AbstractPlatformDomainRuleException {
public ClientFamilyMemberFatherOrCaretakerRequiredException() {
super("error.msg.client.family.member.father.or.caretaker.required",
"Either Father or a Caretaker is required as a family member before this client can be activated.");
}
}
@@ -0,0 +1,34 @@
/**
* 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.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown on client activation when no active "Mother" family member has been recorded. Kept
* separate from {@link ClientFamilyMemberFatherOrCaretakerRequiredException} so the two failure
* reasons surface as distinct errors instead of one merged message.
*/
public class ClientFamilyMemberMotherRequiredException extends AbstractPlatformDomainRuleException {
public ClientFamilyMemberMotherRequiredException() {
super("error.msg.client.family.member.mother.required",
"Mother is required as a family member before this client can be activated.");
}
}
@@ -33,4 +33,12 @@ public interface ClientFamilyMembersWritePlatformService {
CommandProcessingResult deleteFamilyMember(Long familyMemberId, JsonCommand command);
/**
* Enforces that an individual client has "Mother" plus either "Father" or "Caretaker" recorded
* as active family members. No-op for non-individual (entity) clients. Throws
* {@code ClientFamilyMemberMotherRequiredException} or
* {@code ClientFamilyMemberFatherOrCaretakerRequiredException} on failure.
*/
void validateFamilyRequirementsForActivation(Client client);
}
@@ -37,10 +37,13 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import java.util.List;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMembers;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -131,6 +134,47 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
}
}
@Override
public void validateFamilyRequirementsForActivation(final Client client) {
if (client == null || client.getId() == null || client.getLegalForm() == null) {
return;
}
// Family (Mother/Father/Caretaker) is an individual-client concept — see
// assertCanHaveFamilyMembers above; non-individual entities are exempt from this gate.
if (org.apache.fineract.portfolio.client.domain.LegalForm.fromInt(client.getLegalForm()).isEntity()) {
return;
}
final List<ClientFamilyMembers> members = clientFamilyRepository.findAllActiveByClient(client.getId());
boolean hasMother = false;
boolean hasFather = false;
boolean hasCaretaker = false;
for (final ClientFamilyMembers member : members) {
final CodeValue relationship = member.getRelationship();
if (relationship == null || relationship.getLabel() == null) {
continue;
}
final String label = relationship.getLabel().trim().toLowerCase(Locale.ROOT);
switch (label) {
case "mother" -> hasMother = true;
case "father" -> hasFather = true;
case "caretaker" -> hasCaretaker = true;
default -> {
// other relationships don't affect this rule
}
}
}
// Reported as two independent failures rather than one merged message, so the caller can
// tell which specific requirement is unmet.
if (!hasMother) {
throw new ClientFamilyMemberMotherRequiredException();
}
if (!hasFather && !hasCaretaker) {
throw new ClientFamilyMemberFatherOrCaretakerRequiredException();
}
}
@Override
public CommandProcessingResult addFamilyMember(final long clientId, final JsonCommand command) {
@@ -1449,6 +1449,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
validateParentGroupRulesBeforeClientActivation(client);
this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client);
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
@@ -447,6 +447,7 @@
<include file="parts/0424_add_senior_citizen_pwd_to_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0425_add_diploma_degree_qualification.xml" relativeToChangelogFile="true"/>
<include file="parts/0426_add_graduate_education_level.xml" relativeToChangelogFile="true"/>
<include file="parts/0427_add_caretaker_relationship_value.xml" relativeToChangelogFile="true"/>
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- Family-member validation now requires either a Father or a Caretaker (see
client-family-members-step.component.ts familyRelationshipRulesMet) — CARETAKER didn't
exist as a RELATIONSHIP code value (0241_add_client_nominee_table.xml), so a client with
no living/available father had no valid way to satisfy that rule. -->
<changeSet author="CBS" id="0427-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id
WHERE c.code_name = 'RELATIONSHIP' AND cv.code_value = 'CARETAKER'
</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory)
SELECT c.id, 'CARETAKER',
(SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1,
true, false
FROM m_code c
WHERE c.code_name = 'RELATIONSHIP';
</sql>
</changeSet>
</databaseChangeLog>