feat: multi-nominee support, duplicate validation, and FD/RD required fields

Account Creation Enhancements:
- Added multi-nominee support for SB, CA, FD, RD accounts (nominees[] array)
- Backend: saveNomineesIfPresent() validates total share <= 100% before saving
- Added 'nominees' to parameter whitelists (SavingsAccountConstant, DepositsApiConstants)

Duplicate Validation Fixes:
- SB/FD/RD: Fixed duplicate externalId error handler to walk full exception cause
  chain (EclipseLink wraps PSQLException deep) — now matches constraint
  'm_savings_account_external_id_key' and returns proper 400 error
- CA: Added try-catch around both saveAndFlush calls for duplicate externalId
  (constraint: ca_unq_external_id) with throwIfDuplicateExternalId() helper
- CA: Added duplicate product check — blocks creating same product twice for client
- SB/FD/RD: Added JpaSystemException handling via getAllCauseMessages() helper

Current Account (CA) Improvements:
- CurrentAccount entity: added @UniqueConstraint for external_id (ca_unq_external_id)
- CurrentAccountRepository: added countActiveByClientAndProduct() JPQL query
- Liquibase 0298: unique constraint on m_current_account.external_id

FD/RD Required Field Validation (Backend):
- DepositAccountDataValidator: depositPeriod now required for RD (was FD-only)
- DepositAccountDataValidator: mandatoryRecommendedDepositAmount now notNull+positive for RD
- DepositAccountDataValidator: customerSegment now required (notBlank) for both FD and RD

Liquibase Migrations:
- 0297: fix unique nominee per account constraint
- 0298: unique external_id on m_current_account
- 2014: add is_mandatory column to m_savings_account_joint_holders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nitesh Yadav
2026-05-20 08:00:00 +05:30
co-authored by Claude Sonnet 4.6
parent 9d67afec3e
commit cc0ccbb461
18 changed files with 291 additions and 28 deletions
@@ -53,6 +53,17 @@ public interface ClientNomineeRepository extends JpaRepository<ClientNominee, Lo
@Param("nomineeClientId") Long nomineeClientId,
@Param("accountType") String accountType);
@Query("SELECT COUNT(n) FROM ClientNominee n "
+ "WHERE n.client.id = :clientId "
+ "AND n.nomineeClient.id = :nomineeClientId "
+ "AND n.accountNo = :accountNo "
+ "AND n.accountType = :accountType "
+ "AND n.isDeleted = false")
long countActiveByClientNomineeAndAccountNoAndType(@Param("clientId") Long clientId,
@Param("nomineeClientId") Long nomineeClientId,
@Param("accountNo") String accountNo,
@Param("accountType") String accountType);
@Query("SELECT COUNT(n) FROM ClientNominee n "
+ "WHERE n.client.id = :clientId "
+ "AND n.nomineeClient.id = :nomineeClientId "
@@ -537,6 +537,7 @@ public final class DepositsApiConstants {
fixedDepositRequestData.add(SavingsApiConstants.introducerNameParamName);
fixedDepositRequestData.add(SavingsApiConstants.introducerCifNumberParamName);
fixedDepositRequestData.add(SavingsApiConstants.nomineeParamName);
fixedDepositRequestData.add("nominees");
fixedDepositRequestData.add(SavingsApiConstants.linkedAccountIdParamName);
fixedDepositRequestData.add(SavingsApiConstants.linkedAccountTypeParamName);
fixedDepositRequestData.add(SavingsApiConstants.linkedAccountNoParamName);
@@ -603,6 +604,7 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add(SavingsApiConstants.introducerNameParamName);
recurringDepositRequestData.add(SavingsApiConstants.introducerCifNumberParamName);
recurringDepositRequestData.add(SavingsApiConstants.nomineeParamName);
recurringDepositRequestData.add("nominees");
recurringDepositRequestData.add(SavingsApiConstants.linkedAccountIdParamName);
recurringDepositRequestData.add(SavingsApiConstants.linkedAccountTypeParamName);
recurringDepositRequestData.add(SavingsApiConstants.linkedAccountNoParamName);
@@ -41,7 +41,10 @@ import org.apache.fineract.organisation.staff.domain.Staff;
import org.apache.fineract.portfolio.client.domain.Client;
@Entity
@Table(name = "m_current_account")
@Table(name = "m_current_account", uniqueConstraints = {
@jakarta.persistence.UniqueConstraint(columnNames = { "account_no" }, name = "ca_account_no_UNIQUE"),
@jakarta.persistence.UniqueConstraint(columnNames = { "external_id" }, name = "ca_unq_external_id")
})
@Getter
@Setter
public class CurrentAccount extends AbstractAuditableCustom {
@@ -37,4 +37,7 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
}
@@ -36,4 +36,6 @@ public interface ClientNomineeWritePlatformService {
* in the same transaction without a separate API call.
*/
void saveNomineeIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineeJson);
void saveNomineesIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineesJson);
}
@@ -351,6 +351,13 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
if (nomineeClientId.equals(clientId)) return; // self-nomination guard
// Skip if nominee already exists for this exact account
if (nomineeRepository.countActiveByClientNomineeAndAccountNoAndType(clientId, nomineeClientId, accountNo, accountType) > 0) {
log.info("Nominee already exists for clientId={}, nomineeClientId={}, accountNo={}, accountType={} — skipping duplicate insert",
clientId, nomineeClientId, accountNo, accountType);
return;
}
final Client nomineeClient = clientRepository.findOneWithNotFoundDetection(nomineeClientId);
final BigDecimal share = n.has("percentageShare") && !n.get("percentageShare").isJsonNull()
@@ -392,4 +399,33 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
log.warn("Failed to save nominee during account creation for clientId={}, accountNo={}: {}", clientId, accountNo, e.getMessage());
}
}
@Override
public void saveNomineesIfPresent(final Long clientId, final String accountNo, final String accountType, final JsonElement nomineesJson) {
if (nomineesJson == null || nomineesJson.isJsonNull()) return;
if (nomineesJson.isJsonArray()) {
final com.google.gson.JsonArray arr = nomineesJson.getAsJsonArray();
BigDecimal totalShare = BigDecimal.ZERO;
for (com.google.gson.JsonElement el : arr) {
if (!el.isJsonObject()) continue;
final com.google.gson.JsonObject n = el.getAsJsonObject();
if (!n.has("nomineeClientId") || n.get("nomineeClientId").isJsonNull()) continue;
final BigDecimal share = n.has("percentageShare") && !n.get("percentageShare").isJsonNull()
? n.get("percentageShare").getAsBigDecimal() : BigDecimal.ZERO;
totalShare = totalShare.add(share);
}
if (totalShare.compareTo(new BigDecimal("100")) > 0) {
throw new PlatformDataIntegrityException(
"error.msg.nominee.total.share.exceeds.100",
"Total nominee percentage share cannot exceed 100%. Provided total: " + totalShare + "%.",
"percentageShare", totalShare);
}
for (com.google.gson.JsonElement el : arr) {
if (el.isJsonObject()) saveNomineeIfPresent(clientId, accountNo, accountType, el);
}
} else if (nomineesJson.isJsonObject()) {
// fallback: single nominee object
saveNomineeIfPresent(clientId, accountNo, accountType, nomineesJson);
}
}
}
@@ -47,6 +47,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureI
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.customerSegmentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.amountParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargeIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName;
@@ -260,6 +261,9 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(externalIdParamName).value(externalId).notExceedingLengthOf(100);
}
final String customerSegment = this.fromApiJsonHelper.extractStringNamed(customerSegmentParamName, element);
baseDataValidator.reset().parameter(customerSegmentParamName).value(customerSegment).notBlank();
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateParamName, element)) {
final BigDecimal interestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateParamName,
element);
@@ -567,7 +571,7 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(depositAmountParamName).value(depositAmount).notNull().positiveAmount();
}
if (depositType.isFixedDeposit() || fromApiJsonHelper.parameterExists(depositPeriodParamName, element)) {
if (depositType.isFixedDeposit() || depositType.isRecurringDeposit() || fromApiJsonHelper.parameterExists(depositPeriodParamName, element)) {
final Integer depositPeriod = fromApiJsonHelper.extractIntegerSansLocaleNamed(depositPeriodParamName, element);
baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod).notNull().integerGreaterThanZero();
}
@@ -643,7 +647,7 @@ public class DepositAccountDataValidator {
final BigDecimal mandatoryRecommendedDepositAmount = fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(mandatoryRecommendedDepositAmountParamName, element);
baseDataValidator.reset().parameter(mandatoryRecommendedDepositAmountParamName).value(mandatoryRecommendedDepositAmount)
.ignoreIfNull().zeroOrPositiveAmount();
.notNull().positiveAmount();
if (this.fromApiJsonHelper.parameterExists(isMandatoryDepositParamName, element)) {
final Boolean isMandatoryDeposit = this.fromApiJsonHelper.extractBooleanNamed(isMandatoryDepositParamName, element);
@@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
@@ -88,6 +89,13 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
final Client client = clientRepository.findOneWithNotFoundDetection(clientId);
final CurrentProduct product = currentProductRepository.findById(productId)
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
if (currentAccountRepository.countActiveByClientAndProduct(clientId, productId) > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Current Account with the same product.",
"productId", productId);
}
final Office office = officeId != null
? officeRepository.findOneWithNotFoundDetection(officeId)
: client.getOffice();
@@ -185,14 +193,21 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
// Save first to get ID
currentAccountRepository.saveAndFlush(account);
try {
currentAccountRepository.saveAndFlush(account);
} catch (RuntimeException e) {
throwIfDuplicateExternalId(e, account.getExternalId());
throw e;
}
// Generate account number: 00 + GL_CODE(6) + OFFICE_ID(2) + ACCOUNT_ID(5)
final String accountNo = accountNumberGenerator.generate(account);
account.updateAccountNo(accountNo);
// Save nominee if provided in payload (same transaction)
if (json.has("nominee") && !json.get("nominee").isJsonNull()) {
// Save nominees if provided in payload (same transaction)
if (json.has("nominees") && !json.get("nominees").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineesIfPresent(clientId, accountNo, "CB", json.get("nominees"));
} else if (json.has("nominee") && !json.get("nominee").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
}
@@ -213,7 +228,12 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
assemblePartners(account, json.getAsJsonArray("partners"));
}
currentAccountRepository.saveAndFlush(account);
try {
currentAccountRepository.saveAndFlush(account);
} catch (RuntimeException e) {
throwIfDuplicateExternalId(e, account.getExternalId());
throw e;
}
return new CommandProcessingResultBuilder()
.withEntityId(account.getId())
@@ -645,6 +665,20 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
journalEntryRepository.save(entry);
}
private void throwIfDuplicateExternalId(final RuntimeException e, final String externalId) {
Throwable cause = e;
while (cause != null) {
final String msg = cause.getMessage();
if (msg != null && (msg.contains("ca_external_id_UNIQUE") || msg.contains("ca_unq_external_id"))) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.externalid",
"Current Account with this External ID already exists.",
"externalId", externalId);
}
cause = cause.getCause();
}
}
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
if (!enabled) {
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
@@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent;
@@ -134,16 +135,16 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
String msg = "Unknown data integrity issue with savings account.";
String param = null;
Object[] msgArgs;
Throwable checkEx = realCause == null ? dve : realCause;
if (checkEx.getMessage().contains("sa_account_no_UNIQUE")) {
final String causeMsg = getAllCauseMessages(realCause, dve);
if (causeMsg.contains("sa_account_no_UNIQUE")) {
final String accountNo = command.stringValueOfParameterNamed("accountNo");
msgCode += ".duplicate.accountNo";
msg = "Savings account with accountNo " + accountNo + " already exists";
param = "accountNo";
msgArgs = new Object[] { accountNo, dve };
} else if (checkEx.getMessage().contains("sa_external_id_UNIQUE")) {
} else if (causeMsg.contains("sa_external_id_UNIQUE") || causeMsg.contains("m_savings_account_external_id_key")) {
final String externalId = command.stringValueOfParameterNamed("externalId");
msgCode += ".duplicate.externalId";
msgCode = "error.msg.savingsaccount.duplicate.externalId";
msg = "Savings account with externalId " + externalId + " already exists";
param = "externalId";
msgArgs = new Object[] { externalId, dve };
@@ -155,6 +156,18 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
throw ErrorHandler.getMappable(dve, msgCode, msg, param, msgArgs);
}
private String getAllCauseMessages(final Throwable realCause, final Exception dve) {
final StringBuilder sb = new StringBuilder();
Throwable t = realCause != null ? realCause : dve;
while (t != null) {
if (t.getMessage() != null) {
sb.append(t.getMessage()).append(' ');
}
t = t.getCause();
}
return sb.toString();
}
@Transactional
@Override
public CommandProcessingResult submitFDApplication(final JsonCommand command) {
@@ -169,6 +182,17 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy,
DepositAccountType.FIXED_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Fixed Deposit account with the same product.",
"productId", account.productId());
}
}
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
@@ -198,11 +222,14 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
this.accountAssociationsRepository.save(accountAssociations);
}
// Save nominee if provided in payload (same transaction)
if (command.parameterExists("nominee")) {
// Save nominees if provided in payload (same transaction)
final com.google.gson.JsonObject fdRoot = command.parsedJson().getAsJsonObject();
if (fdRoot.has("nominees") && !fdRoot.get("nominees").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineesIfPresent(
account.clientId(), account.getAccountNumber(), "FD", fdRoot.get("nominees"));
} else if (fdRoot.has("nominee") && !fdRoot.get("nominee").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineeIfPresent(
account.clientId(), account.getAccountNumber(), "FD",
command.parsedJson().getAsJsonObject().get("nominee"));
account.clientId(), account.getAccountNumber(), "FD", fdRoot.get("nominee"));
}
final Long savingsId = account.getId();
@@ -240,6 +267,17 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command,
submittedBy, DepositAccountType.RECURRING_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Recurring Deposit account with the same product.",
"productId", account.productId());
}
}
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
this.recurringDepositAccountRepository.save(account);
@@ -268,11 +306,14 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
account.validateApplicableInterestRate();
savingAccountRepository.save(account);
// Save nominee if provided in payload (same transaction)
if (command.parameterExists("nominee")) {
// Save nominees if provided in payload (same transaction)
final com.google.gson.JsonObject rdRoot = command.parsedJson().getAsJsonObject();
if (rdRoot.has("nominees") && !rdRoot.get("nominees").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineesIfPresent(
account.clientId(), account.getAccountNumber(), "RD", rdRoot.get("nominees"));
} else if (rdRoot.has("nominee") && !rdRoot.get("nominee").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineeIfPresent(
account.clientId(), account.getAccountNumber(), "RD",
command.parsedJson().getAsJsonObject().get("nominee"));
account.clientId(), account.getAccountNumber(), "RD", rdRoot.get("nominee"));
}
businessEventNotifierService.notifyPostBusinessEvent(new RecurringDepositAccountCreateBusinessEvent(account));
@@ -47,6 +47,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.dataqueries.data.EntityTables;
import org.apache.fineract.infrastructure.dataqueries.data.StatusEnum;
import org.apache.fineract.infrastructure.dataqueries.service.EntityDatatableChecksWritePlatformService;
@@ -145,6 +146,18 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final AppUser submittedBy = this.context.authenticatedUser();
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(command, submittedBy);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active account with the same product.",
"productId", account.productId());
}
}
this.savingAccountRepository.save(account);
String accountNumber = "";
GroupSavingsIndividualMonitoring gsimAccount = null;
@@ -233,11 +246,14 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
}
}
// end of gsim
// Save nominee if provided in payload (same transaction)
if (command.parameterExists("nominee")) {
// Save nominees if provided in payload (same transaction)
final com.google.gson.JsonObject root = command.parsedJson().getAsJsonObject();
if (root.has("nominees") && !root.get("nominees").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineesIfPresent(
account.clientId(), account.getAccountNumber(), "SB", root.get("nominees"));
} else if (root.has("nominee") && !root.get("nominee").isJsonNull()) {
clientNomineeWritePlatformService.saveNomineeIfPresent(
account.clientId(), account.getAccountNumber(), "SB",
command.parsedJson().getAsJsonObject().get("nominee"));
account.clientId(), account.getAccountNumber(), "SB", root.get("nominee"));
}
final Long savingsId = account.getId();
@@ -701,16 +717,16 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
String msg = "Unknown data integrity issue with savings account.";
String param = null;
Object[] msgArgs;
Throwable checkEx = realCause == null ? dve : realCause;
if (checkEx.getMessage().contains("sa_account_no_UNIQUE")) {
final String causeMsg = getRootCauseMessage(realCause, dve);
if (causeMsg.contains("sa_account_no_UNIQUE")) {
final String accountNo = command.stringValueOfParameterNamed("accountNo");
msgCode += ".duplicate.accountNo";
msg = "Savings account with accountNo " + accountNo + " already exists";
param = "accountNo";
msgArgs = new Object[] { accountNo, dve };
} else if (checkEx.getMessage().contains("sa_externalid_UNIQUE")) {
} else if (causeMsg.contains("sa_external_id_UNIQUE") || causeMsg.contains("m_savings_account_external_id_key")) {
final String externalId = command.stringValueOfParameterNamed("externalId");
msgCode += ".duplicate.externalId";
msgCode = "error.msg.savingsaccount.duplicate.externalId";
msg = "Savings account with externalId " + externalId + " already exists";
param = "externalId";
msgArgs = new Object[] { externalId, dve };
@@ -721,4 +737,16 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, msgCode, msg, param, msgArgs);
}
private String getRootCauseMessage(final Throwable realCause, final Exception dve) {
final StringBuilder sb = new StringBuilder();
Throwable t = realCause != null ? realCause : dve;
while (t != null) {
if (t.getMessage() != null) {
sb.append(t.getMessage()).append(' ');
}
t = t.getCause();
}
return sb.toString();
}
}
@@ -313,4 +313,6 @@
<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"/>
<include file="parts/0297_fix_unique_nominee_per_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0298_unique_external_id_on_current_account.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?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">
<!--
Fix: previous constraint uq_client_nominee_per_account_type was on
(client_id, nominee_client_id, account_type) — this incorrectly blocked the
same nominee from being added to multiple accounts of the same type
(e.g. two SB accounts). The correct uniqueness is per individual account:
(client_id, nominee_client_id, account_no, account_type).
-->
<changeSet id="0297_fix_unique_nominee_per_account" author="fineract" dbms="postgresql">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_client_nominee"/>
</preConditions>
<sql>
DROP INDEX IF EXISTS uq_client_nominee_per_account_type;
CREATE UNIQUE INDEX IF NOT EXISTS uq_client_nominee_per_account
ON m_client_nominee (client_id, nominee_client_id, account_no, account_type)
WHERE is_deleted = false;
</sql>
<rollback>
<sql>
DROP INDEX IF EXISTS uq_client_nominee_per_account;
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>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<changeSet id="0298_unique_external_id_on_current_account" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_current_account"/>
<not><indexExists tableName="m_current_account" indexName="ca_external_id_UNIQUE"/></not>
<not><indexExists tableName="m_current_account" indexName="ca_unq_external_id"/></not>
</preConditions>
<addUniqueConstraint
tableName="m_current_account"
columnNames="external_id"
constraintName="ca_external_id_UNIQUE"/>
</changeSet>
</databaseChangeLog>
@@ -46,7 +46,7 @@ public class SavingsAccountConstant extends SavingsApiConstants {
guardianNameParamName, guardianCifNumberParamName, guardianRelationshipParamName,
guardianClientIdParamName, minorDateOfBirthParamName,
introducerIdParamName, introducerNameParamName, introducerCifNumberParamName,
nomineeParamName, linkedAccountIdParamName, linkedAccountTypeParamName, linkedAccountNoParamName,
nomineeParamName, "nominees", linkedAccountIdParamName, linkedAccountTypeParamName, linkedAccountNoParamName,
accountFacilityConfigParamName, jointHoldersParamName,
balanceSwapOptionParamName, msmeParamName, autoDebitFacilityParamName));
@@ -56,6 +56,9 @@ public interface SavingsAccountRepository extends JpaRepository<SavingsAccount,
@Query("select case when (count (saving) > 0) then 'true' else 'false' end from SavingsAccount saving where saving.client.id = :clientId and saving.status in (100,200,300,303,304)")
boolean doNonClosedSavingAccountsExistForClient(@Param("clientId") Long clientId);
@Query("SELECT COUNT(sa) FROM SavingsAccount sa WHERE sa.client.id = :clientId AND sa.product.id = :productId AND sa.status IN (100, 200, 300, 303, 304)")
long countActiveAccountsByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
@Query("select sa from SavingsAccount sa where sa.client.id is null and sa.group.id = :groupId")
List<SavingsAccount> findByGroupId(@Param("groupId") Long groupId);
@@ -181,4 +181,8 @@ public class SavingsAccountRepositoryWrapper {
public List<Long> findLoanIdsByStatusId(Integer status) {
return null;
}
public long countActiveAccountsByClientAndProduct(final Long clientId, final Long productId) {
return this.repository.countActiveAccountsByClientAndProduct(clientId, productId);
}
}
@@ -36,4 +36,5 @@
<include file="parts/2011_add_facility_charge_mapping.xml" relativeToChangelogFile="true" />
<include file="parts/2012_add_current_account_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2013_add_missing_current_account_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2014_add_is_mandatory_to_joint_holders.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?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.
-->
<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">
<changeSet id="2014_add_is_mandatory_to_joint_holders" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_account_joint_holders"/>
<not><columnExists tableName="m_savings_account_joint_holders" columnName="is_mandatory"/></not>
</preConditions>
<addColumn tableName="m_savings_account_joint_holders">
<column name="is_mandatory" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>