Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6066e0b6 | ||
|
|
4499b8afad | ||
|
|
11a206f42a | ||
|
|
ce8bb7e5ca | ||
|
|
82336cbe98 |
+20
-10
@@ -137,16 +137,7 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
// isApprovedByChecker is only ever true here for a genuine checker approval
|
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||||
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
|
|
||||||
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
|
|
||||||
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
|
|
||||||
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
|
|
||||||
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
|
|
||||||
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
|
|
||||||
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
|
|
||||||
// auto-approve their own submission just by holding it.
|
|
||||||
if (isApprovedByChecker) {
|
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -160,6 +151,25 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||||
|
if (!user.isCheckerSuperUser()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+8
-20
@@ -48,12 +48,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final CommandProcessingService processAndLogCommandService;
|
private final CommandProcessingService processAndLogCommandService;
|
||||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
||||||
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
|
|
||||||
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
|
|
||||||
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
|
|
||||||
// live, independently-configurable global switch (System -> Configurations); deleting this field,
|
|
||||||
// and by extension deciding that switch should stop existing altogether, is a separate decision
|
|
||||||
// from closing this specific self-approval loophole.
|
|
||||||
private final ConfigurationDomainService configurationService;
|
private final ConfigurationDomainService configurationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -129,20 +123,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
AppUser appUser = this.context.authenticatedUser();
|
AppUser appUser = this.context.authenticatedUser();
|
||||||
String permissionCode = commandSource.getPermissionCode();
|
String permissionCode = commandSource.getPermissionCode();
|
||||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||||
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
||||||
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
AppUser maker = commandSource.getMaker();
|
||||||
// permission still lets its holder action a DIFFERENT user's request without needing the
|
if (maker == null) {
|
||||||
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
|
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||||
// longer lets them action their own) and not overridable by the enable-same-maker-checker
|
}
|
||||||
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||||
// configuration is left in place and unused for this decision rather than removed, since
|
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||||
// deleting a still-configurable global setting is a separate decision from this one).
|
}
|
||||||
AppUser maker = commandSource.getMaker();
|
|
||||||
if (maker == null) {
|
|
||||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
|
||||||
}
|
|
||||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
|
||||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
|
||||||
}
|
}
|
||||||
return commandSource;
|
return commandSource;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -213,6 +213,14 @@ public final class DepositsApiConstants {
|
|||||||
public static final String transferDescriptionParamName = "transferDescription";
|
public static final String transferDescriptionParamName = "transferDescription";
|
||||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||||
|
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
|
||||||
|
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
|
||||||
|
// per-request flag rather than a stored account field.
|
||||||
|
public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty";
|
||||||
|
// Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike
|
||||||
|
// waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their
|
||||||
|
// penalty and why.
|
||||||
|
public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId";
|
||||||
public static final String savingsAccounts = "savingsAccounts";
|
public static final String savingsAccounts = "savingsAccounts";
|
||||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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.savings;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
|
||||||
|
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
|
||||||
|
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
|
||||||
|
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
|
||||||
|
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
|
||||||
|
* server regardless of what a client happened to also send for the plain waive-penalty flag.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public enum PrematureClosureReasonType {
|
||||||
|
|
||||||
|
INVALID(0, "prematureClosureReasonType.invalid", false), //
|
||||||
|
NORMAL(100, "prematureClosureReasonType.normal", false), //
|
||||||
|
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
|
||||||
|
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
|
||||||
|
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
|
||||||
|
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
|
||||||
|
|
||||||
|
private final Integer value;
|
||||||
|
private final String code;
|
||||||
|
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
|
||||||
|
* waive-penalty flag also sent -- see the class javadoc. */
|
||||||
|
private final boolean autoWaived;
|
||||||
|
|
||||||
|
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
|
||||||
|
this.value = value;
|
||||||
|
this.code = code;
|
||||||
|
this.autoWaived = autoWaived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PrematureClosureReasonType fromInt(final Integer v) {
|
||||||
|
if (v == null) {
|
||||||
|
return INVALID;
|
||||||
|
}
|
||||||
|
return switch (v) {
|
||||||
|
case 100 -> NORMAL;
|
||||||
|
case 200 -> PREMATURE_EXTENSION;
|
||||||
|
case 300 -> CONVERSION_TO_ANOTHER_TD;
|
||||||
|
case 400 -> DECEASED_SETTLEMENT;
|
||||||
|
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
|
||||||
|
default -> INVALID;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInvalid() {
|
||||||
|
return this.equals(INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-4
@@ -173,12 +173,25 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
|
|||||||
}
|
}
|
||||||
|
|
||||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||||
// Post journal entry if earned interest amount is greater than
|
// A premature FD closure that reprices interest downward (see
|
||||||
// zero
|
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||||
|
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||||
|
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||||
|
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||||
|
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||||
|
// createCashBasedJournalEntriesAndReversalsForSavings already uses for isReversal -- keeps
|
||||||
|
// the two in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on
|
||||||
|
// the cash-based path.
|
||||||
|
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||||
|
if (amountSign != 0) {
|
||||||
|
final boolean isClawback = amountSign < 0;
|
||||||
|
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||||
|
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
||||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, postingAmount,
|
||||||
|
effectiveIsReversal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
|||||||
}
|
}
|
||||||
|
|
||||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||||
// Post journal entry if earned interest amount is greater than
|
// A premature FD closure that reprices interest downward (see
|
||||||
// zero
|
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||||
|
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||||
|
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||||
|
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||||
|
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||||
|
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||||
|
// in sync instead.
|
||||||
|
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||||
|
if (amountSign != 0) {
|
||||||
|
final boolean isClawback = amountSign < 0;
|
||||||
|
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||||
|
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||||
// Routed through the payable and paid accounts the product names, so the society
|
// Routed through the payable and paid accounts the product names, so the society
|
||||||
// can read interest declared but not yet credited as its own balance. Collapses
|
// can read interest declared but not yet credited as its own balance. Collapses
|
||||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||||
// that names neither.
|
// that names neither.
|
||||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||||
savingsId, transactionId, transactionDate, amount, isReversal);
|
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -77,10 +77,12 @@ public class DepositAccountTransactionDataValidator {
|
|||||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||||
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
|
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
|
||||||
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
|
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
|
||||||
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName));
|
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
|
||||||
|
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||||
|
|
||||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(
|
||||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
|
DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||||
|
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||||
|
|
||||||
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||||
|
|||||||
+15
@@ -19,9 +19,11 @@
|
|||||||
package org.apache.fineract.portfolio.savings.domain;
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.MathContext;
|
import java.math.MathContext;
|
||||||
@@ -65,6 +67,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
|||||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||||
|
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||||
@@ -812,6 +815,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||||
Long savingsTransactionId = null;
|
Long savingsTransactionId = null;
|
||||||
|
|
||||||
|
// Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored
|
||||||
|
// preClosurePenalApplicable config -- this is a one-time override for this closure only, read
|
||||||
|
// fresh here since the preview and the actual submit are two separate requests.
|
||||||
|
final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName)
|
||||||
|
&& command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName);
|
||||||
|
// RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see
|
||||||
|
// PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual
|
||||||
|
// closure always matches what the preview showed for the same reason.
|
||||||
|
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType
|
||||||
|
.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName));
|
||||||
|
account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived());
|
||||||
|
|
||||||
// post interest
|
// post interest
|
||||||
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
financialYearBeginningMonth);
|
financialYearBeginningMonth);
|
||||||
|
|||||||
+78
-13
@@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
|
|||||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType;
|
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
|
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
|
||||||
@@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
protected InterestRateChartAssembler chartAssembler;
|
protected InterestRateChartAssembler chartAssembler;
|
||||||
@Transient
|
@Transient
|
||||||
private ConfigurationDomainService configurationDomainService;
|
private ConfigurationDomainService configurationDomainService;
|
||||||
|
/**
|
||||||
|
* Set once per request, immediately before a premature-closure preview or the real closure, from the
|
||||||
|
* maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is
|
||||||
|
* a one-time decision for this specific closure rather than a product/account setting. The product's
|
||||||
|
* own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so
|
||||||
|
* every other premature closure of this same product still applies its penalty as normal; this only
|
||||||
|
* suppresses it for the one request that explicitly asked to waive it.
|
||||||
|
*/
|
||||||
|
@Transient
|
||||||
|
private boolean waivePreClosurePenaltyForThisRequest;
|
||||||
|
|
||||||
protected FixedDepositAccount() {
|
protected FixedDepositAccount() {
|
||||||
//
|
//
|
||||||
@@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
if (this.chart != null) {
|
if (this.chart != null) {
|
||||||
boolean applyPreMaturePenalty = false;
|
boolean applyPreMaturePenalty = false;
|
||||||
BigDecimal penalInterest = BigDecimal.ZERO;
|
BigDecimal penalInterest = BigDecimal.ZERO;
|
||||||
LocalDate depositCloseDate = calculateMaturityDate();
|
// Rate-selection date is always calculateMaturityDate() below -- premature closure prices off
|
||||||
|
// the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one
|
||||||
|
// every regular scheduled interest posting this account ever makes (see this method's 2-arg
|
||||||
|
// overload), so a closure's freshly-recomputed total always matches what the account was
|
||||||
|
// already accruing all along. No separate "actual period held" slab lookup -- deliberately
|
||||||
|
// simple, one rate for the account's whole life.
|
||||||
|
final LocalDate depositCloseDate = calculateMaturityDate();
|
||||||
if (isPreMatureClosure) {
|
if (isPreMatureClosure) {
|
||||||
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) {
|
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) {
|
||||||
applyPreMaturePenalty = true;
|
applyPreMaturePenalty = true;
|
||||||
penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest();
|
penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest();
|
||||||
final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure
|
|
||||||
.depositPreClosureDetail().preClosurePenalInterestOnType();
|
|
||||||
if (preClosurePenalInterestOnType.isWholeTerm()) {
|
|
||||||
depositCloseDate = interestCalculatedUpto();
|
|
||||||
} else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) {
|
|
||||||
depositCloseDate = interestPostingUpToDate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
boolean isInterestTransfer = false;
|
boolean isInterestTransfer = false;
|
||||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||||
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||||
transactions);
|
transactionsForCurrentTermInterestCalculation(transactions));
|
||||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||||
boolean isUserPosting = false;
|
boolean isUserPosting = false;
|
||||||
if (postedAsOnTransactionDates.contains(periodInterval.endDate())) {
|
if (postedAsOnTransactionDates.contains(periodInterval.endDate())) {
|
||||||
@@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
final boolean isInterestTransfer = false;
|
final boolean isInterestTransfer = false;
|
||||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||||
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||||
transactions);
|
transactionsForCurrentTermInterestCalculation(transactions));
|
||||||
|
|
||||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||||
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
|
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
|
||||||
@@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType);
|
this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType);
|
||||||
|
|
||||||
|
// RBI audit trail: record why, not just that, this closure happened -- see
|
||||||
|
// PrematureClosureReasonType. Absent entirely on a request from before this field existed,
|
||||||
|
// which resolves to INVALID/null here rather than a misleading "Normal".
|
||||||
|
final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName);
|
||||||
|
this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId));
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* // withdraw deposit amount before closing the account final Money transactionAmountMoney =
|
* // withdraw deposit amount before closing the account final Money transactionAmountMoney =
|
||||||
* Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw =
|
* Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw =
|
||||||
@@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview)
|
||||||
|
* or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the
|
||||||
|
* same interest calculation this waives a part of. */
|
||||||
|
public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) {
|
||||||
|
this.waivePreClosurePenaltyForThisRequest = waive;
|
||||||
|
}
|
||||||
|
|
||||||
public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure,
|
public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure,
|
||||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
|
||||||
|
|
||||||
@@ -909,10 +931,26 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
|
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interest already posted for the term currently running, NOT the account's whole lifetime.
|
||||||
|
* <p>
|
||||||
|
* A renewed account keeps every prior term's transactions in {@code this.transactions} -- including
|
||||||
|
* whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg)
|
||||||
|
* for a term that has since closed. Summing all of them here double-counted that stale interest: a
|
||||||
|
* premature closure of the CURRENT term subtracted it from the maturity amount
|
||||||
|
* ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never
|
||||||
|
* part of this term's balance to begin with, silently shortchanging the customer by exactly that
|
||||||
|
* amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its
|
||||||
|
* deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason
|
||||||
|
* {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before
|
||||||
|
* the term restarted belongs to a term that has already been settled.
|
||||||
|
*/
|
||||||
private Money totalInterestPosted() {
|
private Money totalInterestPosted() {
|
||||||
|
final LocalDate currentTermStartDate = depositStartDate();
|
||||||
Money interestPostedToDate = Money.zero(this.currency);
|
Money interestPostedToDate = Money.zero(this.currency);
|
||||||
for (final SavingsAccountTransaction transaction : this.transactions) {
|
for (final SavingsAccountTransaction transaction : this.transactions) {
|
||||||
if (transaction.isInterestPostingAndNotReversed()) {
|
if (transaction.isInterestPostingAndNotReversed()
|
||||||
|
&& !DateUtils.isBefore(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||||
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,6 +998,33 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
|
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A renewed term's opening balance is seeded straight to the term's own principal (see
|
||||||
|
* calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF
|
||||||
|
* depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing
|
||||||
|
* transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed
|
||||||
|
* and the new one opened in the same stroke), so without this filter it still falls inside the new
|
||||||
|
* term's posting-period walk and gets applied a SECOND time on top of the already-net seed --
|
||||||
|
* understating the very first day's balance by the old term's entire payout, which silently corrupts
|
||||||
|
* that day's interest for as long as the term keeps re-including it (every premature-closure preview,
|
||||||
|
* and any later maturity recalculation). Scoping to transactions strictly after the term start avoids
|
||||||
|
* the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway.
|
||||||
|
*/
|
||||||
|
private List<SavingsAccountTransaction> transactionsForCurrentTermInterestCalculation(
|
||||||
|
final List<SavingsAccountTransaction> transactions) {
|
||||||
|
if (!isRenewedTerm()) {
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
final LocalDate currentTermStartDate = depositStartDate();
|
||||||
|
final List<SavingsAccountTransaction> scoped = new ArrayList<>();
|
||||||
|
for (final SavingsAccountTransaction transaction : transactions) {
|
||||||
|
if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||||
|
scoped.add(transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
|
||||||
private LocalDate interestCalculatedUpto() {
|
private LocalDate interestCalculatedUpto() {
|
||||||
LocalDate uptoMaturityDate = calculateMaturityDate();
|
LocalDate uptoMaturityDate = calculateMaturityDate();
|
||||||
if (uptoMaturityDate != null) {
|
if (uptoMaturityDate != null) {
|
||||||
|
|||||||
+2
-10
@@ -250,11 +250,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement elsewhere.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,11 +306,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement elsewhere.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-4
@@ -19,8 +19,11 @@
|
|||||||
package org.apache.fineract.portfolio.savings.service;
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||||
|
|
||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
|||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||||
|
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||||
import org.apache.fineract.portfolio.savings.data.DepositAccountData;
|
import org.apache.fineract.portfolio.savings.data.DepositAccountData;
|
||||||
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
||||||
import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData;
|
import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData;
|
||||||
@@ -77,13 +81,32 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
|||||||
// calculate interest before one day of closure date
|
// calculate interest before one day of closure date
|
||||||
final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1);
|
final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1);
|
||||||
final boolean isPreMatureClosure = true;
|
final boolean isPreMatureClosure = true;
|
||||||
|
// Preview-time only -- the real closure re-reads this same box from its own request when it
|
||||||
|
// actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a
|
||||||
|
// maker previewing with the box checked and then submitting without it (or vice versa) gets
|
||||||
|
// exactly what they last confirmed, not what an earlier preview computed.
|
||||||
|
final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element)
|
||||||
|
&& this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element);
|
||||||
|
// RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased
|
||||||
|
// settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox
|
||||||
|
// above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so
|
||||||
|
// the preview a maker sees always matches what the actual closure will compute.
|
||||||
|
final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName,
|
||||||
|
element);
|
||||||
|
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId);
|
||||||
|
final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived();
|
||||||
|
|
||||||
if (depositAccountType.isFixedDeposit()) {
|
if (depositAccountType.isFixedDeposit()) {
|
||||||
final FixedDepositAccount fd = (FixedDepositAccount) account;
|
final FixedDepositAccount fd = (FixedDepositAccount) account;
|
||||||
accountData = FixedDepositAccountData.preClosureDetails(
|
fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty);
|
||||||
account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
|
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
|
// calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's
|
||||||
|
// own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview --
|
||||||
|
// the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here
|
||||||
|
// rather than re-deriving it, to show the maker exactly what applied.
|
||||||
|
accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions,
|
||||||
|
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
|
||||||
} else if (depositAccountType.isRecurringDeposit()) {
|
} else if (depositAccountType.isRecurringDeposit()) {
|
||||||
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
|
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
|
||||||
accountData = RecurringDepositAccountData.preClosureDetails(
|
accountData = RecurringDepositAccountData.preClosureDetails(
|
||||||
|
|||||||
+2
-10
@@ -260,11 +260,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,11 +350,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -264,11 +264,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,11 +357,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -203,11 +203,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,11 +265,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -420,11 +420,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,11 +524,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-12
@@ -172,12 +172,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
|||||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
|
||||||
// it just no longer lets them bypass the hold on their own (matches
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
|
||||||
// equivalent rule for the classic command pipeline).
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,12 +214,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
|||||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
|
||||||
// it just no longer lets them bypass the hold on their own (matches
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
|
||||||
// equivalent rule for the classic command pipeline).
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -882,11 +882,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,11 +968,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
@@ -321,6 +321,14 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
|||||||
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
|
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
|
||||||
|
|
||||||
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
|
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
|
||||||
|
if (parsedElement.isJsonObject()) {
|
||||||
|
// the stored payload keeps a "[PROTECTED]" placeholder for the password rather than
|
||||||
|
// the real value; the actual new password is applied below via request.getPasswordHash(),
|
||||||
|
// so these keys must not reach AppUser.update() or it will try to re-encode the placeholder
|
||||||
|
final JsonObject payloadObject = parsedElement.getAsJsonObject();
|
||||||
|
payloadObject.remove("password");
|
||||||
|
payloadObject.remove("repeatPassword");
|
||||||
|
}
|
||||||
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
|
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
|
||||||
|
|
||||||
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
|
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
|
||||||
|
|||||||
+8
-8
@@ -27,17 +27,17 @@
|
|||||||
#/platform-admin/menu-access works out of the box the first time the app is stood up,
|
#/platform-admin/menu-access works out of the box the first time the app is stood up,
|
||||||
without anyone needing shell/DB access to mint the first key by hand. It is deliberately
|
without anyone needing shell/DB access to mint the first key by hand. It is deliberately
|
||||||
the SAME raw value on every deployment/tenant (requested behaviour) rather than a random
|
the SAME raw value on every deployment/tenant (requested behaviour) rather than a random
|
||||||
one generated per install - the trade-off, on purpose: this value is visible to anyone who
|
one generated per install: this is a deliberate trade-off, since this value is visible to
|
||||||
can read this source file, exactly like a default admin password shipped in a seed script.
|
anyone who can read this source file, exactly like a default admin password shipped in a
|
||||||
It is a bootstrap convenience, not a secret. Rotate it for any deployment that is not purely
|
seed script. It is a bootstrap convenience, not a secret. Rotate it for any deployment that
|
||||||
local/dev: insert a new row with a real random key's hash (see ApiClientCredentialService's
|
is not purely local/dev: insert a new row with a real random key's hash (see
|
||||||
hashing) and either deactivate or DELETE this row - POST /v1/apiclients is intentionally
|
ApiClientCredentialService's hashing) and either deactivate or DELETE this row. POST
|
||||||
disabled (see ApiClientAdminApiResource), so rotation is a direct m_api_client write, same
|
/v1/apiclients is intentionally disabled (see ApiClientAdminApiResource), so rotation is a
|
||||||
as this seed.
|
direct m_api_client write, same as this seed.
|
||||||
|
|
||||||
Raw key (only shown here, since it is fixed and not a secret to rotate away from):
|
Raw key (only shown here, since it is fixed and not a secret to rotate away from):
|
||||||
cbsx_default_platform_operator_key_2026
|
cbsx_default_platform_operator_key_2026
|
||||||
api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase - matching
|
api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase, matching
|
||||||
ApiClientCredentialService#sha256Hex. -->
|
ApiClientCredentialService#sha256Hex. -->
|
||||||
<changeSet id="0012_seed_default_platform_api_client" author="cbs-nitesh">
|
<changeSet id="0012_seed_default_platform_api_client" author="cbs-nitesh">
|
||||||
<preConditions onFail="MARK_RAN">
|
<preConditions onFail="MARK_RAN">
|
||||||
|
|||||||
@@ -156,5 +156,5 @@
|
|||||||
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+47
@@ -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
|
||||||
|
|
||||||
|
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.1.xsd">
|
||||||
|
|
||||||
|
<!-- RBI audit gap: a premature FD/RD closure could waive its penalty (a manual maker checkbox),
|
||||||
|
but nothing recorded WHY, and reasons that must always waive the penalty by policy (premature
|
||||||
|
extension, conversion to another term deposit, deceased settlement, nominee/legal heir
|
||||||
|
closure) were left to the maker's own judgement with no enforcement. This column is the audit
|
||||||
|
evidence: which of those reasons (or a plain "Normal" closure) applied to this specific
|
||||||
|
closure, set once by FixedDepositAccount#prematureClosure and never changed afterward.
|
||||||
|
See PrematureClosureReasonType for the stored values. -->
|
||||||
|
<changeSet author="CBS" id="0588-1-add-premature-closure-reason">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_deposit_account_term_and_preclosure"/>
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="premature_closure_reason_enum"/>
|
||||||
|
</not>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_deposit_account_term_and_preclosure">
|
||||||
|
<column name="premature_closure_reason_enum" type="INTEGER"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
-73
@@ -1,73 +0,0 @@
|
|||||||
<?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">
|
|
||||||
|
|
||||||
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
|
|
||||||
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
|
|
||||||
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
|
|
||||||
dev database - none of that was ever captured by a migration, so a fresh/other environment
|
|
||||||
restored from this changelog history would come up with every one of them still at its
|
|
||||||
originally-seeded false value. Every code here was independently confirmed, via read-only
|
|
||||||
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
|
|
||||||
= false and currently true in the running dev DB with no existing changeset already covering
|
|
||||||
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
|
|
||||||
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
|
|
||||||
|
|
||||||
This changeset only ever moves can_maker_checker from false to true for these exact codes -
|
|
||||||
entity_name, action_name, grouping and every other column are left exactly as their origin
|
|
||||||
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
|
|
||||||
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
|
|
||||||
touched: those already exist for every code below (added earlier by 0583/0586, or by base
|
|
||||||
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
|
|
||||||
|
|
||||||
Grouped by the screen each permission belongs to:
|
|
||||||
- Currency Configuration: UPDATE_CURRENCY
|
|
||||||
- Manage Funds: CREATE_FUND
|
|
||||||
- Working Days: UPDATE_WORKINGDAYS
|
|
||||||
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
|
|
||||||
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
|
|
||||||
- Branch Profile: UPDATE_BRANCHPROFILE
|
|
||||||
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
|
|
||||||
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
|
|
||||||
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
|
|
||||||
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
|
|
||||||
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
|
|
||||||
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
|
|
||||||
- Manage User Agreement: CREATE_USERAGREEMENT
|
|
||||||
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
|
|
||||||
|
|
||||||
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
|
|
||||||
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
|
|
||||||
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
|
|
||||||
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
|
|
||||||
Survey permissions (confirmed still false - never actually toggled on)
|
|
||||||
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
|
|
||||||
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
|
|
||||||
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
|
|
||||||
Organization or Admin -> System - out of scope for this change and left as pre-existing
|
|
||||||
drift for a separate decision. -->
|
|
||||||
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
|
|
||||||
<update tableName="m_permission">
|
|
||||||
<column name="can_maker_checker" valueBoolean="true"/>
|
|
||||||
<where>code IN (
|
|
||||||
'UPDATE_CURRENCY',
|
|
||||||
'CREATE_FUND',
|
|
||||||
'UPDATE_WORKINGDAYS',
|
|
||||||
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
|
|
||||||
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
|
|
||||||
'UPDATE_BRANCHPROFILE',
|
|
||||||
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
|
|
||||||
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
|
|
||||||
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
|
|
||||||
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
|
|
||||||
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
|
|
||||||
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
|
|
||||||
'CREATE_USERAGREEMENT',
|
|
||||||
'UPDATE_BATCH_BUSINESS_STEP'
|
|
||||||
)</where>
|
|
||||||
</update>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
+6
-12
@@ -116,11 +116,7 @@ public class CommandSourceServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
|
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
|
||||||
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
|
|
||||||
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
|
|
||||||
// replaces the old bypass this test used to assert.
|
|
||||||
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
||||||
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
||||||
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
||||||
@@ -129,8 +125,6 @@ public class CommandSourceServiceTest {
|
|||||||
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
||||||
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
||||||
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
||||||
Mockito.when(commandSource.getId()).thenReturn(300L);
|
|
||||||
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
|
|
||||||
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
||||||
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
||||||
|
|
||||||
@@ -138,12 +132,12 @@ public class CommandSourceServiceTest {
|
|||||||
.withCommandId(1L).build();
|
.withCommandId(1L).build();
|
||||||
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
||||||
|
|
||||||
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
|
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
|
||||||
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
|
commandSource, superUser, false);
|
||||||
});
|
|
||||||
|
|
||||||
Mockito.verify(commandSource).markAsAwaitingApproval();
|
Assertions.assertNotNull(result);
|
||||||
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
|
Mockito.verify(commandSource).markAsChecked(superUser);
|
||||||
|
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+7
-2
@@ -674,7 +674,7 @@ public final class FixedDepositAccountData extends DepositAccountData {
|
|||||||
|
|
||||||
public static FixedDepositAccountData preClosureDetails(final Long accountId, BigDecimal maturityAmount,
|
public static FixedDepositAccountData preClosureDetails(final Long accountId, BigDecimal maturityAmount,
|
||||||
final Collection<EnumOptionData> onAccountClosureOptions, final Collection<PaymentTypeData> paymentTypeOptions,
|
final Collection<EnumOptionData> onAccountClosureOptions, final Collection<PaymentTypeData> paymentTypeOptions,
|
||||||
final Collection<SavingsAccountData> savingsAccountDatas) {
|
final Collection<SavingsAccountData> savingsAccountDatas, final BigDecimal effectiveInterestRate) {
|
||||||
|
|
||||||
final Long groupId = null;
|
final Long groupId = null;
|
||||||
final String groupName = null;
|
final String groupName = null;
|
||||||
@@ -689,7 +689,12 @@ public final class FixedDepositAccountData extends DepositAccountData {
|
|||||||
final SavingsAccountStatusEnumData status = null;
|
final SavingsAccountStatusEnumData status = null;
|
||||||
final SavingsAccountApplicationTimelineData timeline = null;
|
final SavingsAccountApplicationTimelineData timeline = null;
|
||||||
final CurrencyData currency = null;
|
final CurrencyData currency = null;
|
||||||
final BigDecimal nominalAnnualInterestRate = null;
|
// Not the account's stored contracted rate -- the rate actually resolved for THIS premature
|
||||||
|
// closure preview (see FixedDepositAccount#getEffectiveInterestRateAsFraction, read back
|
||||||
|
// straight after the calculation by the caller), so a maker can see which of the RBI-default
|
||||||
|
// actual-period rate, a bank's "use contracted rate" override, and any penalty deduction ended
|
||||||
|
// up applying, rather than only the final rupee amount.
|
||||||
|
final BigDecimal nominalAnnualInterestRate = effectiveInterestRate;
|
||||||
final EnumOptionData interestPeriodType = null;
|
final EnumOptionData interestPeriodType = null;
|
||||||
final EnumOptionData interestPostingPeriodType = null;
|
final EnumOptionData interestPostingPeriodType = null;
|
||||||
final EnumOptionData interestCalculationType = null;
|
final EnumOptionData interestCalculationType = null;
|
||||||
|
|||||||
+15
@@ -45,6 +45,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
|||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
|
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
|
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
|
||||||
|
|
||||||
@@ -118,6 +119,20 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
|
|||||||
this.fdAccountNumber = fdAccountNumber;
|
this.fdAccountNumber = fdAccountNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RBI audit evidence: why THIS closure happened, set once by FixedDepositAccount#prematureClosure
|
||||||
|
// and never changed afterward -- see PrematureClosureReasonType. Null on an account that was never
|
||||||
|
// prematurely closed (including one still active), or on one closed before this column existed.
|
||||||
|
@Column(name = "premature_closure_reason_enum")
|
||||||
|
private Integer prematureClosureReason;
|
||||||
|
|
||||||
|
public PrematureClosureReasonType getPrematureClosureReason() {
|
||||||
|
return PrematureClosureReasonType.fromInt(this.prematureClosureReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updatePrematureClosureReason(final PrematureClosureReasonType reason) {
|
||||||
|
this.prematureClosureReason = (reason == null || reason.isInvalid()) ? null : reason.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
// Which renewal cycle of one logical FD this account represents: 0 = originally opened account,
|
// Which renewal cycle of one logical FD this account represents: 0 = originally opened account,
|
||||||
// 1 = first renewal, and so on. Lets the UI present "one FD, many terms" over what is physically
|
// 1 = first renewal, and so on. Lets the UI present "one FD, many terms" over what is physically
|
||||||
// a chain of per-term account rows.
|
// a chain of per-term account rows.
|
||||||
|
|||||||
+28
-2
@@ -158,7 +158,15 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
present.add("taxGroupId");
|
present.add("taxGroupId");
|
||||||
}
|
}
|
||||||
if (command.hasParameter("otherGlMappings")) {
|
if (command.hasParameter("otherGlMappings")) {
|
||||||
f.setOtherGlMappings(command.stringValueOfParameterNamed("otherGlMappings"));
|
// otherGlMappings arrives as a nested JSON object ({assets:[...], liabilities:[...],
|
||||||
|
// expenses:[...]}), the same shape SavingsProduct#update() reads via jsonFragment() --
|
||||||
|
// NOT a JSON string primitive. stringValueOfParameterNamed() only handles the latter and
|
||||||
|
// silently came back blank for the former, so every "Savings Interest Payable"/"Savings
|
||||||
|
// Interest Paid" (and other named GL account) edit was staged with an empty value: it
|
||||||
|
// never reached the pending-edit row, so the checker's diff had nothing to show and
|
||||||
|
// approving replayed no GL account change at all. jsonFragment() is the same call the
|
||||||
|
// live (non-pending) update path already uses for this exact parameter.
|
||||||
|
f.setOtherGlMappings(command.jsonFragment("otherGlMappings"));
|
||||||
present.add("otherGlMappings");
|
present.add("otherGlMappings");
|
||||||
}
|
}
|
||||||
if (command.hasParameter("isDormancyTrackingActive")) {
|
if (command.hasParameter("isDormancyTrackingActive")) {
|
||||||
@@ -355,7 +363,7 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
putNumber(root, submitted, "minBalanceForInterestCalculation", f.getMinBalanceForInterestCalculation());
|
putNumber(root, submitted, "minBalanceForInterestCalculation", f.getMinBalanceForInterestCalculation());
|
||||||
putBoolean(root, submitted, "withHoldTax", f.getWithHoldTax());
|
putBoolean(root, submitted, "withHoldTax", f.getWithHoldTax());
|
||||||
putNumber(root, submitted, "taxGroupId", f.getTaxGroupId());
|
putNumber(root, submitted, "taxGroupId", f.getTaxGroupId());
|
||||||
putString(root, submitted, "otherGlMappings", f.getOtherGlMappings());
|
putJsonFragment(root, submitted, "otherGlMappings", f.getOtherGlMappings());
|
||||||
putBoolean(root, submitted, "isDormancyTrackingActive", f.getIsDormancyTrackingActive());
|
putBoolean(root, submitted, "isDormancyTrackingActive", f.getIsDormancyTrackingActive());
|
||||||
putNumber(root, submitted, "daysToInactive", f.getDaysToInactive());
|
putNumber(root, submitted, "daysToInactive", f.getDaysToInactive());
|
||||||
putNumber(root, submitted, "daysToDormancy", f.getDaysToDormancy());
|
putNumber(root, submitted, "daysToDormancy", f.getDaysToDormancy());
|
||||||
@@ -733,6 +741,24 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #putString}, but for a value that is itself raw JSON (an object/array captured
|
||||||
|
* via {@code JsonCommand#jsonFragment}) rather than a plain string. {@code addProperty} would wrap
|
||||||
|
* it in quotes as an escaped string literal, which {@code jsonFragment} on the replayed command
|
||||||
|
* would then hand back as that literal quoted text instead of the original structure -- breaking
|
||||||
|
* the downstream `other_gl_mappings::jsonb` cast this field's value round-trips through.
|
||||||
|
*/
|
||||||
|
private static void putJsonFragment(final JsonObject root, final Set<String> submitted, final String name, final String rawJson) {
|
||||||
|
if (!submitted.contains(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rawJson == null || rawJson.isBlank()) {
|
||||||
|
root.add(name, com.google.gson.JsonNull.INSTANCE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.add(name, com.google.gson.JsonParser.parseString(rawJson));
|
||||||
|
}
|
||||||
|
|
||||||
private static void putNumber(final JsonObject root, final Set<String> submitted, final String name, final Number value) {
|
private static void putNumber(final JsonObject root, final Set<String> submitted, final String name, final Number value) {
|
||||||
if (submitted.contains(name)) {
|
if (submitted.contains(name)) {
|
||||||
root.addProperty(name, value);
|
root.addProperty(name, value);
|
||||||
|
|||||||
Reference in New Issue
Block a user