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:
Aditya
2026-05-15 11:50:04 +05:30
co-authored by Claude Opus 4.7
parent e1d7db9b7c
commit 6558ea6998
32 changed files with 89504 additions and 213424 deletions
@@ -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);
}
@@ -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();
@@ -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());
@@ -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
@@ -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();
}
}
@@ -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;
};
}
}
@@ -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>
@@ -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>