DSC-2FA login fix + Individual-only link pickers + Title Case names
- DSC-as-2FA login: UI's claimAndValidate() now calls a new
AuthenticationService.applyTwoFactorToken(token, liveSeconds) instead of
validateOTP(). The /v1/login/dsc/{rn}/claim response is already a minted
TFAccessToken — POSTing it to /twofactor/validate fails because that path
expects an in-memory OTPRequest. applyTwoFactorToken runs only the
post-success bookkeeping (interceptor.setTwoFactorAccessToken, setCredentials,
Authentication Success alert that drives router.navigate(['/']), and
sessionStorage persist of {token, validFrom, validTo}). Fixes the
OTPTokenInvalidException users hit after a successful DSC verify+claim.
- Individual-only filter for link-client pickers: ClientTextSearch gains a
legalFormType field; SearchingClientRepository accepts an Integer legalForm
value and adds cb.equal(r.get("legalForm"), ...) to the spec.
ClientSearchService.parseLegalForm tolerates INDIVIDUAL/PERSON/1 and
NON_INDIVIDUAL/ENTITY/2; unknown = no filter so a typo doesn't blank results.
UI: clients.service.ts searchByText gains a 7th optional arg; six link
pickers (guardian, nominee, family-member across create flow and post-create
tabs) now pass 'INDIVIDUAL'. General client list and signatory picker are
intentionally unchanged.
- Title-case client names on persistence: ClientWritePlatformServiceJpa
RepositoryImpl now runs first/middle/last/full name fields through
toTitleCase() on both create and update paths. "aditya kumar vyas",
"ADITYA KUMAR VYAS", "Aditya Kumar Vyas" all persist as "Aditya Kumar Vyas".
Whitespace-split title case; preserves hyphens/apostrophes inside words
without re-capitalising after them so clerks verifying against ID documents
see predictable output.
- Migration 0296: unique constraint to prevent duplicate nominee per account
type (the underlying repository + service changes also persisted in this
commit complete the enforcement path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e1d7db9b7c
commit
6558ea6998
+16
@@ -21,8 +21,24 @@ package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientFamilyMembersRepository
|
||||
extends JpaRepository<ClientFamilyMembers, Long>, JpaSpecificationExecutor<ClientFamilyMembers> {
|
||||
|
||||
/**
|
||||
* Counts non-deleted family members for a client that hold a given relationship code, optionally
|
||||
* ignoring one specific family member (used at update time so a row doesn't conflict with itself).
|
||||
* Backs the single-use relationship rule for Mother / Father / Spouse.
|
||||
*/
|
||||
@Query("SELECT COUNT(fm) FROM ClientFamilyMembers fm "
|
||||
+ "WHERE fm.client.id = :clientId "
|
||||
+ "AND fm.relationship.id = :relationshipId "
|
||||
+ "AND fm.isDeleted = false "
|
||||
+ "AND (:excludeFamilyMemberId IS NULL OR fm.id <> :excludeFamilyMemberId)")
|
||||
long countActiveByClientAndRelationship(@Param("clientId") Long clientId,
|
||||
@Param("relationshipId") Long relationshipId,
|
||||
@Param("excludeFamilyMemberId") Long excludeFamilyMemberId);
|
||||
|
||||
}
|
||||
|
||||
+61
@@ -27,11 +27,15 @@ import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeFormatterBuilder;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
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 org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientFamilyMembers;
|
||||
@@ -47,6 +51,16 @@ import org.springframework.stereotype.Service;
|
||||
public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamilyMembersWritePlatformService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientFamilyMembersWritePlatformServiceImpl.class);
|
||||
|
||||
/**
|
||||
* Relationship labels that may appear at most once per client. Compared case-insensitively
|
||||
* against the CodeValue label, so admins can rename the values to match local spellings
|
||||
* without breaking the rule. Common synonyms ({@code husband}/{@code wife}) are treated as
|
||||
* spouse so the rule isn't defeated by data entry.
|
||||
*/
|
||||
private static final Set<String> SINGLE_USE_RELATIONSHIPS = Set.of(
|
||||
"mother", "father", "spouse", "husband", "wife");
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final CodeValueRepository codeValueRepository;
|
||||
private final ClientFamilyMembersRepository clientFamilyRepository;
|
||||
@@ -65,6 +79,38 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the "single-use" rule for relationships in {@link #SINGLE_USE_RELATIONSHIPS}.
|
||||
* Throws {@link PlatformApiDataValidationException} when a duplicate is detected so the UI
|
||||
* gets a clean validation error in the standard envelope.
|
||||
*
|
||||
* @param clientId the parent client
|
||||
* @param relationship the CodeValue being applied; may be {@code null} (e.g. user left it blank)
|
||||
* @param excludeMemberId {@code null} on create, the row id on update — prevents a row from
|
||||
* conflicting with itself when remarks or other non-relationship fields
|
||||
* change
|
||||
*/
|
||||
private void validateRelationshipUniqueness(final Long clientId, final CodeValue relationship,
|
||||
final Long excludeMemberId) {
|
||||
if (clientId == null || relationship == null || relationship.getLabel() == null) {
|
||||
return;
|
||||
}
|
||||
final String label = relationship.getLabel().trim().toLowerCase(Locale.ROOT);
|
||||
if (!SINGLE_USE_RELATIONSHIPS.contains(label)) {
|
||||
return;
|
||||
}
|
||||
final long existing = clientFamilyRepository.countActiveByClientAndRelationship(
|
||||
clientId, relationship.getId(), excludeMemberId);
|
||||
if (existing > 0) {
|
||||
final String displayName = relationship.getLabel();
|
||||
throw new PlatformApiDataValidationException(java.util.List.of(ApiParameterError.parameterError(
|
||||
"error.msg.client.family.member.relationship.duplicate",
|
||||
"A family member with relationship '" + displayName + "' already exists for this client. "
|
||||
+ "Only one " + displayName + " is permitted.",
|
||||
"relationshipId", relationship.getId())));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult addFamilyMember(final long clientId, final JsonCommand command) {
|
||||
|
||||
@@ -125,6 +171,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
|
||||
dateOfBirth = command.localDateValueOfParameterNamed("dateOfBirth");
|
||||
|
||||
// Single-use relationship guard (Mother / Father / Spouse) — refuse the add before we
|
||||
// construct the entity so the DB stays consistent and the caller gets a clean validation
|
||||
// error rather than a generic IO failure.
|
||||
validateRelationshipUniqueness(clientId, relationship, null);
|
||||
|
||||
ClientFamilyMembers clientFamilyMembers = ClientFamilyMembers.fromJson(client, firstName, middleName, lastName, qualification,
|
||||
mobileNumber, age, isDependent, relationship, maritalStatus, gender, dateOfBirth, profession,
|
||||
nominationType, percentageShare, remarks);
|
||||
@@ -299,6 +350,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
familyMember.setMarriageAuthority(member.get("marriageAuthority").getAsString());
|
||||
}
|
||||
|
||||
// Single-use relationship guard, evaluated per row so a batch payload with two
|
||||
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
||||
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(familyMember);
|
||||
|
||||
}
|
||||
@@ -447,6 +502,12 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
}
|
||||
}
|
||||
|
||||
// Single-use relationship guard. Exclude the row being updated so it doesn't conflict with
|
||||
// itself when only non-relationship fields changed.
|
||||
final Client ownerForValidation = clientFamilyMember.getClient();
|
||||
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
||||
clientFamilyMember.getRelationship(), familyMemberId);
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
||||
|
||||
+27
-9
@@ -150,16 +150,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
final Map<String, Object> changes = kyc.update(command);
|
||||
|
||||
if (!changes.isEmpty()) {
|
||||
// If PAN-related fields changed on a VERIFIED record, reset PAN verification
|
||||
if (currentStatus.isVerified() && changes.containsKey("panNumber")) {
|
||||
// Only changes to identifier fields invalidate prior verifications. A remarks edit or
|
||||
// risk-category bump does not — keeping it as VERIFIED is the correct end state.
|
||||
final boolean panChanged = changes.containsKey("panNumber");
|
||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
||||
|
||||
if (panChanged && currentStatus.isVerified()) {
|
||||
kyc.resetPanVerification();
|
||||
changes.put("panVerified", false);
|
||||
}
|
||||
// Move to IN_PROGRESS when documents are updated
|
||||
if (!kyc.getKycStatus().isInProgress()) {
|
||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
||||
kyc.markInProgress();
|
||||
changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue());
|
||||
}
|
||||
// Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED.
|
||||
// Handles the common case of editing remarks / risk on a fully-verified KYC.
|
||||
kyc.tryAutoVerify();
|
||||
if (kyc.getKycStatus().isVerified()) {
|
||||
changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue());
|
||||
}
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
}
|
||||
|
||||
@@ -972,15 +983,22 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
}
|
||||
|
||||
// Same audit semantics as the explicit updateKyc endpoint:
|
||||
// • PAN-edited on a VERIFIED row → reset PAN verification (forces re-verify)
|
||||
// • Any change moves status to IN_PROGRESS so the row is no longer mistaken for a
|
||||
// completed KYC until verify-pan / verify-aadhaar run again.
|
||||
if (currentStatus.isVerified() && changes.containsKey("panNumber")) {
|
||||
// • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications.
|
||||
// • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED.
|
||||
// • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed —
|
||||
// fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS.
|
||||
final boolean panChanged = changes.containsKey("panNumber");
|
||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
||||
|
||||
if (currentStatus.isVerified() && panChanged) {
|
||||
kyc.resetPanVerification();
|
||||
}
|
||||
if (!kyc.getKycStatus().isInProgress()) {
|
||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
||||
kyc.markInProgress();
|
||||
}
|
||||
kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
|
||||
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
|
||||
|
||||
+29
@@ -79,6 +79,11 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
|
||||
}
|
||||
final Client nomineeClient = this.clientRepository.findOneWithNotFoundDetection(nomineeClientId);
|
||||
|
||||
// ── Linked-nominee uniqueness per (account owner, account type) ──
|
||||
// Same person can be a nominee on a Savings (SB) AND a Current (CB) account, but not twice
|
||||
// within the same account type — even across different account numbers of that type.
|
||||
validateNomineeUniquePerAccountType(clientId, nomineeClientId, accountType, null);
|
||||
|
||||
// ── Percentage share validation (per account, not per client) ──
|
||||
final BigDecimal newShare = command.bigDecimalValueOfParameterNamed("percentageShare");
|
||||
validatePercentageShareForAccount(accountNo, accountType, null, newShare);
|
||||
@@ -143,6 +148,8 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
|
||||
throw new PlatformDataIntegrityException("error.msg.nominee.cannot.be.self",
|
||||
"Client cannot be their own nominee.", "nomineeClientId");
|
||||
}
|
||||
// Re-check uniqueness when the nominee link is being changed on update.
|
||||
validateNomineeUniquePerAccountType(clientId, nomineeClientId, nominee.getAccountType(), nomineeId);
|
||||
final Client nomineeClient = this.clientRepository.findOneWithNotFoundDetection(nomineeClientId);
|
||||
nominee.setNomineeClient(nomineeClient);
|
||||
}
|
||||
@@ -265,6 +272,28 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the rule that the same linked nominee may appear at most once per (account owner,
|
||||
* account type). Same person CAN still be nominee for the owner's Savings (SB) and Current (CB)
|
||||
* accounts simultaneously — uniqueness is scoped by accountType, not accountNo.
|
||||
* {@code excludeNomineeId} is set on update so a row doesn't conflict with itself.
|
||||
*/
|
||||
private void validateNomineeUniquePerAccountType(final Long clientId, final Long nomineeClientId,
|
||||
final String accountType, final Long excludeNomineeId) {
|
||||
if (clientId == null || nomineeClientId == null || accountType == null || accountType.isBlank()) {
|
||||
return;
|
||||
}
|
||||
final long existing = nomineeRepository.countActiveByClientNomineeAndAccountType(
|
||||
clientId, nomineeClientId, accountType, excludeNomineeId);
|
||||
if (existing > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.nominee.already.linked.for.account.type",
|
||||
"This person is already a nominee for one of the client's "
|
||||
+ accountType + " accounts. Same nominee can appear only once per account type "
|
||||
+ "(but is allowed across different account types).",
|
||||
"nomineeClientId", nomineeClientId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique nominee identifier: NOM-{TYPE}{ACCNO}-{SEQ}
|
||||
* e.g. NOM-SB00123456-01, NOM-CB00123456-02, NOM-LA00123456-01
|
||||
|
||||
+49
-8
@@ -317,10 +317,14 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
final String mobileNo = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
||||
final String alternateMobileNo = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
||||
final String emailAddress = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
||||
final String firstname = command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName);
|
||||
final String middlename = command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName);
|
||||
final String lastname = command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName);
|
||||
final String fullname = command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName);
|
||||
// Title-case names at the boundary so every persistence path (REST, internal,
|
||||
// bulk import) stores "Aditya Kumar Vyas" regardless of how the caller cased the
|
||||
// input. This is the single write seam for new clients — normalising here means we
|
||||
// never have to re-clean the data downstream (display name, KYC compare, reports).
|
||||
final String firstname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName));
|
||||
final String middlename = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName));
|
||||
final String lastname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName));
|
||||
final String fullname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName));
|
||||
final boolean isStaff = command.booleanPrimitiveValueOfParameterNamed(ClientApiConstants.isStaffParamName);
|
||||
final LocalDate dataOfBirth = command.localDateValueOfParameterNamed(ClientApiConstants.dateOfBirthParamName);
|
||||
|
||||
@@ -672,26 +676,29 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
// Title-case incoming names so an update with "ADITYA" or "aditya" still ends up
|
||||
// stored as "Aditya". Compare the *normalised* value against the persisted one so
|
||||
// we don't churn the audit log with a no-op change when the only difference is case.
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.firstnameParamName, clientForUpdate.getFirstname())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName));
|
||||
changes.put(ClientApiConstants.firstnameParamName, newValue);
|
||||
clientForUpdate.setFirstname(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.middlenameParamName, clientForUpdate.getMiddlename())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName));
|
||||
changes.put(ClientApiConstants.middlenameParamName, newValue);
|
||||
clientForUpdate.setMiddlename(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.lastnameParamName, clientForUpdate.getLastname())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName));
|
||||
changes.put(ClientApiConstants.lastnameParamName, newValue);
|
||||
clientForUpdate.setLastname(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.fullnameParamName, clientForUpdate.getFullname())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName));
|
||||
changes.put(ClientApiConstants.fullnameParamName, newValue);
|
||||
clientForUpdate.setFullname(newValue);
|
||||
}
|
||||
@@ -1574,4 +1581,38 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-case a person name. "aditya kumar vyas" / "ADITYA KUMAR VYAS" → "Aditya Kumar Vyas".
|
||||
* Splits on whitespace, uppercases the first character of each word and lowercases the rest;
|
||||
* preserves hyphens / apostrophes inside words but does NOT re-capitalise after them (so
|
||||
* "smith-jones" → "Smith-jones", which keeps the rule simple and predictable for clerks
|
||||
* verifying KYC names against ID documents). Null / blank → null so we don't accidentally
|
||||
* persist an empty string in place of an unset field.
|
||||
*/
|
||||
private static String toTitleCase(final String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = raw.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final String[] parts = trimmed.split("\\s+");
|
||||
final StringBuilder sb = new StringBuilder(trimmed.length());
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
final String p = parts[i];
|
||||
if (p.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sb.append(Character.toUpperCase(p.charAt(0)));
|
||||
if (p.length() > 1) {
|
||||
sb.append(p.substring(1).toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+22
-1
@@ -24,6 +24,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.PagedRequest;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.LegalForm;
|
||||
import org.apache.fineract.portfolio.client.service.search.domain.ClientSearchData;
|
||||
import org.apache.fineract.portfolio.client.service.search.domain.ClientTextSearch;
|
||||
import org.apache.fineract.portfolio.client.service.search.mapper.ClientSearchDataMapper;
|
||||
@@ -59,9 +60,29 @@ public class ClientSearchService {
|
||||
String requestSearchText = request.map(ClientTextSearch::getText).orElse(null);
|
||||
String searchText = Objects.toString(requestSearchText, "");
|
||||
boolean activeOnly = request.map(ClientTextSearch::getActiveOnly).filter(Boolean::booleanValue).isPresent();
|
||||
Integer legalFormValue = request.map(ClientTextSearch::getLegalFormType).map(ClientSearchService::parseLegalForm).orElse(null);
|
||||
|
||||
Pageable pageable = searchRequest.toPageable();
|
||||
|
||||
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly).map(clientSearchDataMapper::map);
|
||||
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue)
|
||||
.map(clientSearchDataMapper::map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the request-level legal-form hint to the integer stored on m_client.legal_form_enum.
|
||||
* Accepts a few aliases callers in the wild use ("INDIVIDUAL", "PERSON", "NON_INDIVIDUAL",
|
||||
* "ENTITY") and the bare numeric strings. Anything else returns null = no filter, which is the
|
||||
* safe default — an unrecognised value should not silently drop all clients from results.
|
||||
*/
|
||||
private static Integer parseLegalForm(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String v = raw.trim().toUpperCase();
|
||||
return switch (v) {
|
||||
case "INDIVIDUAL", "PERSON", "1" -> LegalForm.PERSON.getValue();
|
||||
case "NON_INDIVIDUAL", "NONINDIVIDUAL", "ENTITY", "2" -> LegalForm.ENTITY.getValue();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -31,4 +31,14 @@ public class ClientTextSearch {
|
||||
* clients should never appear. Defaults to false to preserve behavior of the general client list.
|
||||
*/
|
||||
private Boolean activeOnly;
|
||||
|
||||
/**
|
||||
* Restricts results to a single legal form. Accepts "INDIVIDUAL" (legalFormEnum=1) or
|
||||
* "NON_INDIVIDUAL" / "ENTITY" (legalFormEnum=2). Used by guardian / nominee / family-member
|
||||
* link-client pickers, where only natural persons make sense as linkable parties — picking an
|
||||
* entity as a "guardian" or "spouse" is a data-entry error, so the backend filter is the
|
||||
* authoritative gate (the SPA also passes the same hint, but a hostile or older client must
|
||||
* not be able to link entities by omitting the param). Defaults to null = all legal forms.
|
||||
*/
|
||||
private String legalFormType;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#
|
||||
# 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
|
||||
@@ -23,7 +25,7 @@ fineract.node-id=${FINERACT_NODE_ID:1}
|
||||
|
||||
fineract.security.basicauth.enabled=${FINERACT_SECURITY_BASICAUTH_ENABLED:true}
|
||||
fineract.security.oauth2.enabled=${FINERACT_SECURITY_OAUTH_ENABLED:false}
|
||||
fineract.security.2fa.enabled=${FINERACT_SECURITY_2FA_ENABLED:false}
|
||||
fineract.security.2fa.enabled=${FINERACT_SECURITY_2FA_ENABLED:true}
|
||||
fineract.security.hsts.enabled=${FINERACT_SECURITY_HSTS_ENABLED:false}
|
||||
|
||||
#CORS configuration
|
||||
@@ -44,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:root}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -405,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:root}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
@@ -664,3 +666,24 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
|
||||
|
||||
# ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
|
||||
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
|
||||
|
||||
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
|
||||
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
|
||||
fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp}
|
||||
fineract.security.otp.ttl-seconds=${FINERACT_OTP_TTL_SECONDS:300}
|
||||
fineract.security.otp.max-attempts=${FINERACT_OTP_MAX_ATTEMPTS:5}
|
||||
|
||||
# ── Spring Mail (email OTP, alerts, password reset) ──────────────────────────
|
||||
# IMPORTANT: the password below is a Gmail App Password (NOT the account password).
|
||||
# Keep env-var override for prod — set SPRING_MAIL_PASSWORD in production environments.
|
||||
spring.mail.host=${SPRING_MAIL_HOST:smtp.gmail.com}
|
||||
spring.mail.port=${SPRING_MAIL_PORT:587}
|
||||
spring.mail.username=${SPRING_MAIL_USERNAME:inform.notifications@gmail.com}
|
||||
spring.mail.password=${SPRING_MAIL_PASSWORD:vjvgdqxsivbipefw}
|
||||
spring.mail.properties.mail.smtp.auth=${SPRING_MAIL_SMTP_AUTH:true}
|
||||
spring.mail.properties.mail.smtp.starttls.enable=${SPRING_MAIL_STARTTLS:true}
|
||||
spring.mail.properties.mail.transport.protocol=smtp
|
||||
|
||||
fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com}
|
||||
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
||||
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
|
||||
|
||||
@@ -307,4 +307,5 @@
|
||||
<include file="parts/0293_create_m_dsc_challenge.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0294_add_tfa_token_to_dsc_challenge.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0295_unique_email_mobile_on_m_appuser.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0296_unique_nominee_per_account_type.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?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
|
||||
-->
|
||||
<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.1.xsd">
|
||||
|
||||
<!--
|
||||
Belt-and-braces DB constraint matching the service-level uniqueness check in
|
||||
ClientNomineeWritePlatformServiceImpl: the same person may not be linked as a nominee
|
||||
more than once per (account owner, account type). Soft-deleted rows are excluded via the
|
||||
WHERE clause so revoke + re-add stays possible.
|
||||
|
||||
Partial unique index (Postgres). If the platform ever runs on MariaDB / MySQL the WHERE
|
||||
clause must be dropped — JpaSpecification fallback in the service will still enforce the
|
||||
rule but at slightly higher cost.
|
||||
-->
|
||||
<changeSet id="0296_unique_nominee_per_account_type" author="fineract" dbms="postgresql">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_client_nominee"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_client_nominee_per_account_type
|
||||
ON m_client_nominee (client_id, nominee_client_id, account_type)
|
||||
WHERE is_deleted = false;
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>DROP INDEX IF EXISTS uq_client_nominee_per_account_type;</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user