Merge remote-tracking branch 'origin/dev-latest' into feature/dds-agent-settlement-reports

# Conflicts:
#	fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java
#	fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java
#	fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
This commit is contained in:
tarunmalviya
2026-09-23 10:23:37 +05:30
507 changed files with 58374 additions and 8871 deletions
@@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.interestratechart.InterestRateChartS
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -67,6 +68,28 @@ public class InterestRateChart extends AbstractPersistableCustom<Long> {
@OneToMany(mappedBy = "interestRateChart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<InterestRateChartSlab> chartSlabs = new HashSet<>();
/**
* Whether this is a rate version the product currently offers.
* <p>
* Kept as an explicit flag rather than inferred from the from/end dates, so retiring a rate version is a deliberate
* act and the dates stay purely the historical record. New charts start active.
*/
@Column(name = "is_active", nullable = false)
private boolean active = true;
public boolean isActive() {
return this.active;
}
/** Retires this rate version, so it stops being picked for new and renewing deposits. */
public void deactivate() {
this.active = false;
}
public void activate() {
this.active = true;
}
protected InterestRateChart() {
//
}
@@ -123,6 +123,10 @@ public class InterestRateChartFields {
return compare != null && DateUtils.isAfter(getFromDate(), compare);
}
public String getName() {
return this.name;
}
public LocalDate getFromDate() {
return this.fromDate;
}
@@ -177,6 +177,10 @@ public class InterestRateChartSlabFields {
return isGreater;
}
public String description() {
return this.description;
}
public Integer periodType() {
return this.periodType;
}
@@ -43,6 +43,13 @@ public final class DepositAccountInterestRateChartSlabData {
private final BigDecimal annualInterestRate;
private final CurrencyData currency;
/**
* The senior-citizen premium published on the CBS Base Interest Rate master for this tenure band, carried onto the
* account's own chart slab so it survives independently of whatever the master shows today. Null for a slab priced
* off the product's own chart (which has no senior-citizen concept) or for any Savings/RD slab.
*/
private final BigDecimal seniorCitizenRate;
// associations
private Collection<DepositAccountInterestIncentiveData> incentives;
@@ -66,7 +73,7 @@ public final class DepositAccountInterestRateChartSlabData {
public static DepositAccountInterestRateChartSlabData instance(final Long id, final String description, final EnumOptionData periodType,
final Integer fromPeriod, final Integer toPeriod, final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo,
final BigDecimal annualInterestRate, final CurrencyData currency) {
final BigDecimal annualInterestRate, final CurrencyData currency, final BigDecimal seniorCitizenRate) {
final Collection<EnumOptionData> periodTypes = null;
final Collection<EnumOptionData> entityTypeOptions = null;
final Collection<EnumOptionData> attributeNameOptions = null;
@@ -78,7 +85,8 @@ public final class DepositAccountInterestRateChartSlabData {
final Collection<DepositAccountInterestIncentiveData> incentives = null;
return new DepositAccountInterestRateChartSlabData(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, incentives, currency, periodTypes, entityTypeOptions, attributeNameOptions,
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions);
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions,
seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData from(final InterestRateChartSlabData chartSlabData) {
@@ -90,12 +98,14 @@ public final class DepositAccountInterestRateChartSlabData {
fromProdIncentives.add(DepositAccountInterestIncentiveData.from(incentive));
}
}
// the product-level chart this comes from has no senior-citizen concept at all
final BigDecimal seniorCitizenRate = null;
return new DepositAccountInterestRateChartSlabData(id, chartSlabData.description(), chartSlabData.periodType(),
chartSlabData.fromPeriod(), chartSlabData.toPeriod(), chartSlabData.amountRangeFrom(), chartSlabData.amountRangeTo(),
chartSlabData.annualInterestRate(), fromProdIncentives, chartSlabData.currency(), chartSlabData.periodTypes(),
chartSlabData.entityTypeOptions(), chartSlabData.attributeNameOptions(), chartSlabData.conditionTypeOptions(),
chartSlabData.incentiveTypeOptions(), chartSlabData.genderOptions(), chartSlabData.clientTypeOptions(),
chartSlabData.clientClassificationOptions());
chartSlabData.clientClassificationOptions(), seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData withTemplate(final DepositAccountInterestRateChartSlabData chartSlab,
@@ -106,7 +116,7 @@ public final class DepositAccountInterestRateChartSlabData {
return new DepositAccountInterestRateChartSlabData(chartSlab.id, chartSlab.description, chartSlab.periodType, chartSlab.fromPeriod,
chartSlab.toPeriod, chartSlab.amountRangeFrom, chartSlab.amountRangeTo, chartSlab.annualInterestRate, chartSlab.incentives,
chartSlab.currency, periodTypes, entityTypeOptions, attributeNameOptions, conditionTypeOptions, incentiveTypeOptions,
genderOptions, clientTypeOptions, clientClassificationOptions);
genderOptions, clientTypeOptions, clientClassificationOptions, chartSlab.seniorCitizenRate);
}
private DepositAccountInterestRateChartSlabData(final Long id, final String description, final EnumOptionData periodType,
@@ -115,7 +125,8 @@ public final class DepositAccountInterestRateChartSlabData {
final CurrencyData currency, final Collection<EnumOptionData> periodTypes, final Collection<EnumOptionData> entityTypeOptions,
final Collection<EnumOptionData> attributeNameOptions, final Collection<EnumOptionData> conditionTypeOptions,
final Collection<EnumOptionData> incentiveTypeOptions, final Collection<CodeValueData> genderOptions,
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions) {
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions,
final BigDecimal seniorCitizenRate) {
this.id = id;
this.description = description;
this.periodType = periodType;
@@ -125,6 +136,7 @@ public final class DepositAccountInterestRateChartSlabData {
this.amountRangeTo = amountRangeTo;
this.annualInterestRate = annualInterestRate;
this.currency = currency;
this.seniorCitizenRate = seniorCitizenRate;
this.periodTypes = periodTypes;
this.incentives = incentivesData;
this.attributeNameOptions = attributeNameOptions;
@@ -0,0 +1,83 @@
/**
* 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.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
/**
* One row of {@code m_facility_charge_mapping}: which {@link org.apache.fineract.portfolio.charge.domain.Charge}
* is applied when a named account facility (ATM_FACILITY, UPI_FACILITY, ...) is enabled. {@link #chargeOptions}
* is populated only on the template response, for the admin screen's "change the mapped charge" dropdown.
*/
@Getter
@Setter
public class FacilityChargeMappingData {
private Long id;
private String facilityType;
private Long chargeId;
private String chargeName;
private BigDecimal defaultAmount;
private Integer chargeTimeType;
private Integer chargeAppliesTo;
private Boolean isActive;
/**
* False when the mapped charge has since been deleted or deactivated (and true whenever there is simply no
* charge mapped at all). {@link #chargeName} still shows whatever name was cached at mapping time either
* way, so the admin screen needs this flag to tell "correctly mapped" apart from "was mapped, now broken".
*/
private Boolean chargeAvailable;
private List<FacilityChargeOptionData> chargeOptions;
public FacilityChargeMappingData() {}
public FacilityChargeMappingData(final Long id, final String facilityType, final Long chargeId, final String chargeName,
final BigDecimal defaultAmount, final Integer chargeTimeType, final Integer chargeAppliesTo, final Boolean isActive,
final Boolean chargeAvailable) {
this.id = id;
this.facilityType = facilityType;
this.chargeId = chargeId;
this.chargeName = chargeName;
this.defaultAmount = defaultAmount;
this.chargeTimeType = chargeTimeType;
this.chargeAppliesTo = chargeAppliesTo;
this.isActive = isActive;
this.chargeAvailable = chargeAvailable;
}
@Getter
@Setter
public static class FacilityChargeOptionData {
private final Long id;
private final String name;
private final BigDecimal amount;
public FacilityChargeOptionData(final Long id, final String name, final BigDecimal amount) {
this.id = id;
this.name = name;
this.amount = amount;
}
}
}
@@ -138,6 +138,75 @@ public final class FixedDepositAccountData extends DepositAccountData {
private Boolean isSeniorCitizen;
private java.math.BigDecimal seniorCitizenInterestRate;
// Reinvestment trail: which account this one was reinvested FROM (set on the new account) and
// which account it was reinvested INTO (derived by reverse lookup on the old account), so staff
// can trace a "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure both directions
// instead of the link disappearing once the old account closes.
private Long reinvestedFromAccountId;
private String reinvestedFromAccountNo;
private Long reinvestedIntoAccountId;
private String reinvestedIntoAccountNo;
public void setReinvestmentLinks(final Long reinvestedFromAccountId, final String reinvestedFromAccountNo,
final Long reinvestedIntoAccountId, final String reinvestedIntoAccountNo) {
this.reinvestedFromAccountId = reinvestedFromAccountId;
this.reinvestedFromAccountNo = reinvestedFromAccountNo;
this.reinvestedIntoAccountId = reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = reinvestedIntoAccountNo;
}
// The account number the customer should keep seeing across renewals -- the ORIGINAL account's
// account_no, carried forward onto every account created by reinvesting it. Falls back to this
// account's own accountNo when it has never been reinvested (see DepositAccountReadPlatformServiceImpl).
private String fdAccountNumber;
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
/** Which renewal cycle of that FD this account is: 0 = originally opened, 1 = first renewal, etc. */
private Integer renewalCycleNo;
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
/**
* Every term of this FD, oldest first: one entry per account in the renewal chain, each carrying that term's own
* account number, cycle number, dates, principal and maturity amount. Lets the account view and the deposit
* certificate present "one FD, many terms" without the caller having to walk the chain itself.
*/
private java.util.List<java.util.Map<String, Object>> renewalHistory;
public void setRenewalHistory(final java.util.List<java.util.Map<String, Object>> renewalHistory) {
this.renewalHistory = renewalHistory;
}
/**
* FD Special only: the real, engine-computed interest schedule across the account's whole term -- one entry
* per posting period (fromDate, toDate, interestAmount) -- so staff/customers can see exactly how much
* interest is projected to post each month instead of a flat rate/12 approximation. Null for every other
* FD type (see FixedDepositAccountsApiResource#retrieveOne).
*/
private java.util.List<java.util.Map<String, Object>> projectedInterestSchedule;
private BigDecimal totalProjectedInterest;
private BigDecimal totalValueAtMaturity;
public void setProjectedInterestSchedule(final java.util.List<java.util.Map<String, Object>> projectedInterestSchedule) {
this.projectedInterestSchedule = projectedInterestSchedule;
}
/**
* Server-computed sum of projectedInterestSchedule, and principal + that sum -- the same "what does the
* customer end up with overall" total the account view shows, computed once here instead of the
* frontend re-summing the schedule array itself, so there is a single source of truth (matching how
* maturityAmount itself already works).
*/
public void setProjectedInterestTotals(final BigDecimal totalProjectedInterest, final BigDecimal totalValueAtMaturity) {
this.totalProjectedInterest = totalProjectedInterest;
this.totalValueAtMaturity = totalValueAtMaturity;
}
// CBS account-level fields (stored per-account in m_savings_account)
private String accountStatus;
private String modeOfOperation;
@@ -329,6 +398,13 @@ public final class FixedDepositAccountData extends DepositAccountData {
this.jointHolders = src.jointHolders;
this.isSeniorCitizen = src.isSeniorCitizen;
this.seniorCitizenInterestRate = src.seniorCitizenInterestRate;
this.reinvestedFromAccountId = src.reinvestedFromAccountId;
this.reinvestedFromAccountNo = src.reinvestedFromAccountNo;
this.reinvestedIntoAccountId = src.reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo;
this.fdAccountNumber = src.fdAccountNumber;
this.renewalCycleNo = src.renewalCycleNo;
this.renewalHistory = src.renewalHistory;
this.subStatus = src.subStatus;
this.reasonForBlock = src.reasonForBlock;
this.lastActiveTransactionDate = src.lastActiveTransactionDate;
@@ -76,6 +76,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// for account close
private final Collection<EnumOptionData> onAccountClosureOptions;
private final Collection<PaymentTypeData> paymentTypeOptions;
private final Collection<EnumOptionData> maturityInstructionOptions;
// import fields
private transient Integer rowIndex;
@@ -364,6 +365,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
this.savingsAccounts = null;
this.onAccountClosureOptions = null;
this.paymentTypeOptions = null;
this.maturityInstructionOptions = null;
this.rowIndex = rowIndex;
this.dateFormat = dateFormat;
this.locale = locale;
@@ -394,6 +396,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<SavingsAccountData> savingsAccountDatas = null;
final Integer recurringFrequency = null;
final EnumOptionData recurringFrequencyType = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(depositAccountData.id, depositAccountData.accountNo, depositAccountData.externalId,
depositAccountData.groupId, depositAccountData.groupName, depositAccountData.clientId, depositAccountData.clientName,
@@ -416,7 +419,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas, expectedFirstDepositOnDate,
totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments,
isCalendarInherited, recurringFrequency, recurringFrequencyType, depositAccountData.withHoldTax,
depositAccountData.taxGroup);
depositAccountData.taxGroup, maturityInstructionOptions);
}
public static RecurringDepositAccountData withInterestChartAndRecurringDetails(final RecurringDepositAccountData account,
@@ -441,7 +444,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, recurringFrequency, recurringFrequencyType,
account.withHoldTax, account.taxGroup);
account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
return result;
}
@@ -463,11 +466,13 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions = null;
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<SavingsAccountData> savingsAccountDatas = account.savingsAccounts;
final Collection<EnumOptionData> maturityInstructionOptions = account.maturityInstructionOptions;
return withTemplateOptions(account, productOptions, fieldOfficerOptions, interestCompoundingPeriodTypeOptions,
interestPostingPeriodTypeOptions, interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions,
lockinPeriodFrequencyTypeOptions, withdrawalFeeTypeOptions, transactions, charges, chargeOptions,
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions);
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
final RecurringDepositAccountData result = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
@@ -486,10 +491,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
template.periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, template.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
// Merge accountTypeOptions and customerSegmentOptions from template
if (template.getAccountTypeOptions() != null) result.setAccountTypeOptions(template.getAccountTypeOptions());
@@ -507,7 +512,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> lockinPeriodFrequencyTypeOptions, final Collection<EnumOptionData> withdrawalFeeTypeOptions,
final Collection<SavingsAccountTransactionData> transactions, final Collection<SavingsAccountChargeData> charges,
final Collection<ChargeData> chargeOptions, final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions,
final Collection<EnumOptionData> periodFrequencyTypeOptions) {
final Collection<EnumOptionData> periodFrequencyTypeOptions, final Collection<SavingsAccountData> savingsAccountDatas,
final Collection<EnumOptionData> maturityInstructionOptions) {
final RecurringDepositAccountData r2 = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
account.clientId, account.clientName, account.depositProductId, account.depositProductName, account.fieldOfficerId,
@@ -524,10 +530,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, maturityInstructionOptions);
r2.copyCbsFieldsFrom(account);
return r2;
}
@@ -609,6 +615,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName,
fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -622,7 +629,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData preClosureDetails(final Long accountId, final BigDecimal maturityAmount,
@@ -700,6 +708,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(accountId, accountNo, externalId, groupId, groupName, clientId, clientName, productId,
productName, fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -713,7 +722,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData withClosureTemplateDetails(final RecurringDepositAccountData account,
@@ -739,7 +749,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
closureResult.copyCbsFieldsFrom(account);
return closureResult;
}
@@ -774,7 +784,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final BigDecimal totalOverdueAmount, final Integer noOfOverdueInstallments, final boolean isMandatoryDeposit,
final boolean allowWithdrawal, final boolean adjustAdvanceTowardsFuturePayments, final boolean isCalendarInherited,
final Integer recurringFrequency, final EnumOptionData recurringFrequencyType, final boolean withHoldTax,
final TaxGroupData taxGroup) {
final TaxGroupData taxGroup, final Collection<EnumOptionData> maturityInstructionOptions) {
super(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName, fieldofficerId, fieldofficerName,
status, timeline, currency, nominalAnnualInterestRate, interestPeriodType, interestPostingPeriodType,
@@ -818,6 +828,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// account close template options
this.onAccountClosureOptions = onAccountClosureOptions;
this.paymentTypeOptions = paymentTypeOptions;
this.maturityInstructionOptions = maturityInstructionOptions;
}
@Override
@@ -48,7 +48,8 @@ public class SavingsAccountConstant extends SavingsApiConstants {
introducerIdParamName, introducerNameParamName, introducerCifNumberParamName,
nomineeParamName, "nominees", linkedAccountIdParamName, linkedAccountTypeParamName, linkedAccountNoParamName,
accountFacilityConfigParamName, jointHoldersParamName,
balanceSwapOptionParamName, msmeParamName, autoDebitFacilityParamName, introducerRequiredParamName, shareLinkingEnabledParamName));
balanceSwapOptionParamName, msmeParamName, autoDebitFacilityParamName, introducerRequiredParamName, shareLinkingEnabledParamName,
shareAccountIdParamName));
/**
* These parameters will match the class level parameters of {@link SavingsAccountData}. Where possible, we try to
@@ -56,8 +56,9 @@ public class SavingsProductAccountingDataValidator {
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue())
.value(savingsReferenceAccountId).notNull().integerGreaterThanZero();
}
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)
|| ignoreExistenceValidation) {
// Transfers In Suspense is not offered by the savings product screen, so it is validated only
// when actually supplied — ignoreExistenceValidation must not make it mandatory on create.
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
final Long transfersInSuspenseAccountId = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
@@ -85,22 +86,21 @@ public class SavingsProductAccountingDataValidator {
.value(incomeFromPenaltyId).notNull().integerGreaterThanZero();
}
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element)
|| ignoreExistenceValidation) {
// Overdraft Control / Income From Interest / Write Off are likewise not offered by the
// savings product screen, so they too are validated only when actually supplied.
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element)) {
final Long overdraftControlAccountId = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
.value(overdraftControlAccountId).notNull().integerGreaterThanZero();
}
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)
|| ignoreExistenceValidation) {
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
final Long incomeFromInterest = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
.value(incomeFromInterest).notNull().integerGreaterThanZero();
}
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)
|| ignoreExistenceValidation) {
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
final Long writtenoff = fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenoff)
@@ -105,6 +105,7 @@ public class SavingsProductDataValidator {
SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(),
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(),
SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
isDormancyTrackingActiveParamName, daysToDormancyParamName, daysToInactiveParamName, daysToEscheatParamName,
allowOverdraftParamName, overdraftLimitParamName, nominalAnnualInterestRateOverdraftParamName,
minOverdraftForInterestCalculationParamName, SavingsApiConstants.minRequiredBalanceParamName,
@@ -460,6 +461,15 @@ public class SavingsProductDataValidator {
baseDataValidator.reset().parameter(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue())
.value(escheatLiabilityAccountId).notNull().integerGreaterThanZero();
}
// Optional — a product saved without one still closes, on the fallback the closure
// carries. Only the shape is checked, so clearing the field is allowed.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) {
final Long roundingAdjustmentAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue())
.value(roundingAdjustmentAccountId).ignoreIfNull().integerGreaterThanZero();
}
}
validateOverdraftParams(baseDataValidator, element);
@@ -0,0 +1,158 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* Record that an escheated balance was actually remitted to RBI's Depositor Education and Awareness
* Fund.
*
* <p>
* Escheat and remittance are two separate events. The nightly job closes the account and takes the
* balance out; sending that money to RBI happens outside this system, on RBI's own schedule. Until
* a row exists here the balance is out of the customer's account but not accounted for as having
* reached the fund, and the escheat report says exactly that rather than assuming it was sent.
*
* <p>
* A customer can claim an unclaimed balance at any time even after remittance: the bank pays them
* and recovers the amount from the fund. {@code refundedOn} records that, so a remittance that has
* since been drawn back is not counted twice.
*
* <p>
* One row per savings account — an account's balance goes to DEAF once.
*/
@Entity
@Table(name = "m_deaf_remittance")
public class DeafRemittance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "savings_account_id", nullable = false, unique = true)
private Long savingsAccountId;
@Column(name = "client_id", nullable = false)
private Long clientId;
/** The amount taken out of the account at escheat, which is what is owed to the fund. */
@Column(name = "amount", nullable = false, scale = 6, precision = 19)
private BigDecimal amount;
/** The day the account was closed and the balance removed. */
@Column(name = "escheated_on", nullable = false)
private LocalDate escheatedOn;
/** The day the money was sent to RBI. */
@Column(name = "remitted_on", nullable = false)
private LocalDate remittedOn;
/** RBI's acknowledgement for the remittance, as printed on their advice. */
@Column(name = "deaf_reference", length = 100)
private String deafReference;
/** Set only if the customer later claimed the balance and it was recovered from the fund. */
@Column(name = "refunded_on")
private LocalDate refundedOn;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "recorded_by_user_id")
private Long recordedByUserId;
@Column(name = "recorded_on", nullable = false)
private LocalDateTime recordedOn;
protected DeafRemittance() {
// JPA
}
private DeafRemittance(final Long savingsAccountId, final Long clientId, final BigDecimal amount,
final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks,
final Long recordedByUserId) {
this.savingsAccountId = savingsAccountId;
this.clientId = clientId;
this.amount = amount;
this.escheatedOn = escheatedOn;
this.remittedOn = remittedOn;
this.deafReference = deafReference;
this.remarks = remarks;
this.recordedByUserId = recordedByUserId;
this.recordedOn = LocalDateTime.now();
}
public static DeafRemittance record(final Long savingsAccountId, final Long clientId, final BigDecimal amount,
final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks,
final Long recordedByUserId) {
return new DeafRemittance(savingsAccountId, clientId, amount, escheatedOn, remittedOn, deafReference, remarks,
recordedByUserId);
}
/** Records that the customer claimed the balance back and it was recovered from the fund. */
public void markRefunded(final LocalDate refundedOn) {
this.refundedOn = refundedOn;
}
public Long getId() {
return id;
}
public Long getSavingsAccountId() {
return savingsAccountId;
}
public Long getClientId() {
return clientId;
}
public BigDecimal getAmount() {
return amount;
}
public LocalDate getEscheatedOn() {
return escheatedOn;
}
public LocalDate getRemittedOn() {
return remittedOn;
}
public String getDeafReference() {
return deafReference;
}
public LocalDate getRefundedOn() {
return refundedOn;
}
public String getRemarks() {
return remarks;
}
}
@@ -0,0 +1,32 @@
/**
* 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.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DeafRemittanceRepository extends JpaRepository<DeafRemittance, Long> {
Optional<DeafRemittance> findBySavingsAccountId(Long savingsAccountId);
boolean existsBySavingsAccountId(Long savingsAccountId);
List<DeafRemittance> findBySavingsAccountIdIn(List<Long> savingsAccountIds);
}
@@ -28,12 +28,16 @@ import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlab;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculation;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculationFactory;
@@ -147,6 +151,9 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom<L
if (effectiveInterestRate == null || effectiveInterestRate.compareTo(BigDecimal.ZERO) == 0) {
effectiveInterestRate = slab.slabFields().annualInterestRate();
}
// Only one slab should ever match a given period/amount; stop at the first match instead of
// letting a later slab in iteration order silently overwrite it.
break;
}
}
@@ -156,4 +163,86 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom<L
public boolean isPrimaryGroupingByAmount() {
return this.chartFields.isPrimaryGroupingByAmount();
}
/**
* Re-snapshots this account's chart from the product's current chart, in place.
* <p>
* Used when an FD renews on the same account: the new term must be priced off whatever the product offers on the
* renewal date, not the rates frozen when the account was first opened. This has to mutate the existing row rather
* than swap in a new chart, because the account-to-chart mapping is one-to-one on the chart's savings_account_id, so
* a second row for the same account would break loading it.
*/
public void refreshFrom(final InterestRateChart productChart) {
this.chartFields = productChart.chartFields();
// orphanRemoval on chartSlabs deletes the superseded slab rows.
setOfChartSlabs().clear();
for (final InterestRateChartSlab productSlab : productChart.setOfChartSlabs()) {
this.chartSlabs.add(DepositAccountInterestRateChartSlabs.from(productSlab, this));
}
}
/**
* A slab's rate-by-tenure fields plus its senior-citizen premium, travelling together.
* <p>
* The premium isn't part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level chart slab table, which has no CBS senior-citizen concept -- so it rides alongside as a plain pair
* rather than being added to the shared type.
*/
public static final class SlabSnapshot {
private final InterestRateChartSlabFields fields;
private final BigDecimal seniorCitizenRate;
public SlabSnapshot(final InterestRateChartSlabFields fields, final BigDecimal seniorCitizenRate) {
this.fields = fields;
this.seniorCitizenRate = seniorCitizenRate;
}
public InterestRateChartSlabFields getFields() {
return this.fields;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
}
/**
* This chart's slabs as they stand right now, each paired with its senior-citizen premium. Read before
* {@link #refreshFromSlabSnapshots} overwrites them, so a renewing FD can archive the term that is closing rather than
* losing its rate card the moment the new term's rates replace it.
*/
public List<SlabSnapshot> currentSlabSnapshots() {
final List<SlabSnapshot> snapshots = new ArrayList<>();
for (final DepositAccountInterestRateChartSlabs slab : setOfChartSlabs()) {
snapshots.add(new SlabSnapshot(slab.slabFields(), slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* Re-snapshots this account's chart from an explicit set of slabs (with their senior-citizen premiums), in place.
* <p>
* Used when the rates come from the CBS base interest-rate master rather than from the product's own chart -- that
* master is the rate card staff maintain and see on screen, so a renewing deposit priced off anything else would
* disagree with what the bank publishes. Mutates the existing row for the same reason
* {@link #refreshFrom(InterestRateChart)} does: the account-to-chart mapping is one-to-one on the chart's
* savings_account_id, so a second row for the same account would break loading it.
* <p>
* Does nothing when handed no slabs, leaving the account on the chart it already has, so a missing or empty rate
* version can never leave a deposit with no rates at all.
*/
public void refreshFromSlabSnapshots(final InterestRateChartFields chartFields, final Collection<SlabSnapshot> snapshots) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
if (chartFields != null) {
this.chartFields = chartFields;
}
setOfChartSlabs().clear();
for (final SlabSnapshot snapshot : snapshots) {
this.chartSlabs
.add(DepositAccountInterestRateChartSlabs.fromFields(snapshot.getFields(), this, snapshot.getSeniorCitizenRate()));
}
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -26,6 +27,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -47,6 +49,16 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
@OneToMany(mappedBy = "depositAccountInterestRateChartSlabs", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<DepositAccountInterestIncentives> interestIncentives = new HashSet<>();
/**
* The senior-citizen premium for this slab's tenure band, as published on the CBS Base Interest Rates screen at the
* time this chart was set. Not part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level {@code m_interest_rate_slab} table, which has no CBS senior-citizen concept; kept as a column of its
* own here instead, on the account-level table only. Null for a slab copied from a product chart (which never carries
* one) or for a slab that predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
protected DepositAccountInterestRateChartSlabs() {
//
}
@@ -59,6 +71,10 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
this.interestIncentives = interestIncentives;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
public void setDepositAccountInterestRateChart(DepositAccountInterestRateChart depositAccountInterestRateChart) {
this.depositAccountInterestRateChart = depositAccountInterestRateChart;
}
@@ -71,6 +87,19 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
return this.slabFields;
}
/**
* Builds a slab straight from its field values, for a chart taken from a source that is not a product
* {@link InterestRateChartSlab} -- specifically the CBS base interest-rate master, which is the bank's published rate
* card and carries no incentive rules.
*/
public static DepositAccountInterestRateChartSlabs fromFields(final InterestRateChartSlabFields slabFields,
final DepositAccountInterestRateChart depositAccountInterestRateChart, final BigDecimal seniorCitizenRate) {
final DepositAccountInterestRateChartSlabs slab = new DepositAccountInterestRateChartSlabs(slabFields,
depositAccountInterestRateChart, new HashSet<>());
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
public static DepositAccountInterestRateChartSlabs from(InterestRateChartSlab interestRateChartSlab,
DepositAccountInterestRateChart depositAccountInterestRateChart) {
InterestRateChartSlabFields slabFields = interestRateChartSlab.slabFields();
@@ -89,6 +89,49 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
@Column(name = "transfer_to_savings_account_id")
private Long transferToSavingsAccountId;
// Set on the NEW account created by a "Re-Invest" maturity closure, pointing back at the account
// it was reinvested from. There is no separate "reinvested into" column on the OLD account -- that
// direction is derived by looking up whichever row has this account's id here (see
// DepositAccountReadPlatformServiceImpl), so the relationship needs tracking in only one place.
@Column(name = "reinvested_from_savings_id")
private Long reinvestedFromSavingsId;
public Long getReinvestedFromSavingsId() {
return this.reinvestedFromSavingsId;
}
public void setReinvestedFromSavingsId(final Long reinvestedFromSavingsId) {
this.reinvestedFromSavingsId = reinvestedFromSavingsId;
}
// The account_no of the FIRST account in a renewal chain, carried forward onto every account
// created by reinvesting it. Null on an account that has never been reinvested -- callers should
// fall back to that account's own account_no in that case (see FixedDepositAccount.reInvest()).
@Column(name = "fd_account_number", length = 20)
private String fdAccountNumber;
public String getFdAccountNumber() {
return this.fdAccountNumber;
}
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
// 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
// a chain of per-term account rows.
@Column(name = "renewal_cycle_no")
private Integer renewalCycleNo;
public Integer getRenewalCycleNo() {
return this.renewalCycleNo == null ? 0 : this.renewalCycleNo;
}
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
protected DepositAccountTermAndPreClosure() {
}
@@ -297,6 +340,32 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
this.onAccountClosureType = onClosureType.getValue();
}
/**
* Re-applies the three standing-instruction fields that make an FD Special account pay interest out
* monthly and transfer the principal automatically at maturity, overriding whatever a modify-application
* request just changed them to. Called by {@code FixedDepositAccount#modifyApplication} for SPECIAL-type
* accounts, since {@link #update(JsonCommand, DataValidatorBuilder)} otherwise lets any of these three be
* unset -- including by mere omission of maturityInstructionId/transferToSavingsId from the request body.
*/
public void forceMonthlyPayoutToLinkedSavings(final Long transferToSavingsId) {
this.transferInterestToLinkedAccount = true;
this.transferToSavingsAccountId = transferToSavingsId;
this.onAccountClosureType = DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue();
}
/**
* Copies the standing instructions -- maturity instruction, its payout target and the interest-transfer flag -- from
* the term being renewed.
* <p>
* These describe what the customer asked the deposit to do at every maturity, not a one-off choice for a single term,
* so a renewed deposit that lost them would mature once more and then sit idle instead of rolling over again.
*/
public void carryForwardStandingInstructionsFrom(final DepositAccountTermAndPreClosure previousTerm) {
this.onAccountClosureType = previousTerm.onAccountClosureType;
this.transferToSavingsAccountId = previousTerm.transferToSavingsAccountId;
this.transferInterestToLinkedAccount = previousTerm.transferInterestToLinkedAccount;
}
public boolean isReinvestOnClosure() {
return DepositAccountOnClosureType.fromInt(this.onAccountClosureType).isReinvest();
}
@@ -317,6 +386,9 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
final LocalDate expectedFirstDepositOnDate = null;
final Boolean transferInterestToLinkedAccount = false;
// Left blank here on purpose: this method is shared with Recurring Deposit renewal, so carrying
// standing instructions forward is done by the caller that wants it (see
// FixedDepositAccount#reInvest) rather than changed for both product types at once.
final DepositAccountOnClosureType accountOnClosureType = null;
final Long transferToSavingsId = null;
return DepositAccountTermAndPreClosure.createNew(preClosureDetail, depositTermDetail, account, actualDepositAmount, maturityAmount,
@@ -68,9 +68,6 @@ public class FacilityConfigEditFields {
@Column(name = "fc_inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "fc_withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "fc_deposit_facility_kyc")
private Boolean depositFacilityKyc;
@@ -324,6 +324,42 @@ public class FixedDepositProduct extends SavingsProduct {
return applicableChart;
}
/**
* The rate chart the product currently offers for a deposit priced on the given date: a chart flagged active, that has
* come into force by that date and has not been superseded. Where more than one qualifies, the most recently effective
* wins.
* <p>
* Differs from {@link #applicableChart} in three ways that matter when pricing a renewal. That method ignores the
* active flag entirely; it treats an open-ended chart (no end date) as valid only up to today's business date, so a
* deposit renewing on a future maturity date finds no chart at all; and it returns the first match from an unordered
* set, so two charts covering the same date resolve arbitrarily.
* <p>
* Returns null when the product offers no active chart in force on that date.
*/
public InterestRateChart activeChart(final LocalDate target) {
if (this.charts == null || target == null) {
return null;
}
InterestRateChart activeChart = null;
for (final InterestRateChart chart : this.charts) {
if (!chart.isActive()) {
continue; // retired rate version
}
final LocalDate fromDate = chart.getFromDate();
final LocalDate endDate = chart.getEndDate();
if (fromDate == null || fromDate.isAfter(target)) {
continue; // not yet in force
}
if (endDate != null && endDate.isBefore(target)) {
continue; // already superseded
}
if (activeChart == null || fromDate.isAfter(activeChart.getFromDate())) {
activeChart = chart;
}
}
return activeChart;
}
public DepositProductTermAndPreClosure depositProductTermAndPreClosure() {
return this.productTermAndPreClosure;
}
@@ -417,7 +417,6 @@ public final class ProductPendingEditFieldMapper {
f.setNeftFacility(booleanOf(command, "accountFacilityConfig", "neftFacility"));
f.setRtgsFacility(booleanOf(command, "accountFacilityConfig", "rtgsFacility"));
f.setInterBankTotalCapFacility(booleanOf(command, "accountFacilityConfig", "interBankTotalCapFacility"));
f.setWithdrawalFacility(booleanOf(command, "accountFacilityConfig", "withdrawalFacility"));
f.setDepositFacilityKyc(booleanOf(command, "accountFacilityConfig", "depositFacilityKyc"));
f.setVirtualDebitCard(booleanOf(command, "accountFacilityConfig", "virtualDebitCard"));
return true;
@@ -441,7 +440,6 @@ public final class ProductPendingEditFieldMapper {
config.addProperty("neftFacility", f.getNeftFacility());
config.addProperty("rtgsFacility", f.getRtgsFacility());
config.addProperty("interBankTotalCapFacility", f.getInterBankTotalCapFacility());
config.addProperty("withdrawalFacility", f.getWithdrawalFacility());
config.addProperty("depositFacilityKyc", f.getDepositFacilityKyc());
config.addProperty("virtualDebitCard", f.getVirtualDebitCard());
root.add("accountFacilityConfig", config);
@@ -547,10 +545,21 @@ public final class ProductPendingEditFieldMapper {
/** Returns null if "charges" wasn't present at all, else the (possibly empty) list of charge ids. */
public static List<Long> extractChargeIds(final JsonCommand command) {
if (!command.hasParameter("charges")) {
return extractChargeIds(command, "charges");
}
/**
* Same as {@link #extractChargeIds(JsonCommand)}, but reads a caller-chosen JSON field instead of the
* "charges" every other product type uses. Share products alone submit this array under
* {@code chargesSelected} (see {@code ShareProductApiConstants.charges_paramname}) -- calling the
* default overload for a share product silently finds nothing, since {@code hasParameter("charges")}
* is always false for that payload.
*/
public static List<Long> extractChargeIds(final JsonCommand command, final String paramName) {
if (!command.hasParameter(paramName)) {
return null;
}
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("charges");
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get(paramName);
final List<Long> ids = new java.util.ArrayList<>();
if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
@@ -563,13 +572,23 @@ public final class ProductPendingEditFieldMapper {
}
public static void writeChargesToJson(final JsonObject root, final List<ProductPendingEditCharge> charges) {
writeChargesToJson(root, charges, "charges");
}
/**
* Same as {@link #writeChargesToJson(JsonObject, List)}, but writes under a caller-chosen field name.
* The replayed JSON built here is fed straight back into the same product-update parser the original
* submission went through, so the field name must match whatever that product type's own submit
* payload used -- {@code chargesSelected} for share products, {@code charges} for every other type.
*/
public static void writeChargesToJson(final JsonObject root, final List<ProductPendingEditCharge> charges, final String paramName) {
final JsonArray array = new JsonArray();
for (final ProductPendingEditCharge charge : charges) {
final JsonObject entry = new JsonObject();
entry.addProperty("id", charge.getChargeId());
array.add(entry);
}
root.add("charges", array);
root.add(paramName, array);
}
// ---- accountFacilityLimits (1:many, satellite table) -------------------------------------------
@@ -605,19 +624,29 @@ public final class ProductPendingEditFieldMapper {
// ---- GL array mappings (1:many, satellite table) -----------------------------------------------
// The GL-account field is named differently per mapping array in the product payload -- payment
// channels carry "fundSourceAccountId", fee/penalty arrays carry "incomeAccountId" (see
// AccountingConstants.LoanProductAccountingParams). Reading the wrong name leaves gl_account_id
// null, which the satellite table rejects, so each array declares its own field name here and the
// same name is written back when the pending edit is rebuilt into a product-update payload.
private static final String PAYMENT_CHANNEL_GL_FIELD = "fundSourceAccountId";
private static final String INCOME_ACCOUNT_GL_FIELD = "incomeAccountId";
public static List<ProductPendingEditGlMapping> extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) {
final List<ProductPendingEditGlMapping> mappings = new java.util.ArrayList<>();
boolean any = false;
any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL,
"paymentTypeId", mappings);
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", mappings);
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD, mappings);
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId",
INCOME_ACCOUNT_GL_FIELD, mappings);
any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId",
mappings);
INCOME_ACCOUNT_GL_FIELD, mappings);
return any ? mappings : null;
}
private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName,
final String mappingType, final String idFieldName, final List<ProductPendingEditGlMapping> out) {
final String mappingType, final String idFieldName, final String glFieldName,
final List<ProductPendingEditGlMapping> out) {
if (!command.hasParameter(paramName)) {
return false;
}
@@ -625,8 +654,13 @@ public final class ProductPendingEditFieldMapper {
if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
final JsonObject o = el.getAsJsonObject();
final Long idFieldValue = o.has(idFieldName) ? o.get(idFieldName).getAsLong() : null;
final Long glAccountId = o.has("glAccountId") ? o.get("glAccountId").getAsLong() : null;
final Long idFieldValue = longOrNull(o, idFieldName);
final Long glAccountId = longOrNull(o, glFieldName);
// A mapping is only meaningful with both halves present; keeping a half-filled row
// would fail the satellite table's NOT NULL constraint and abort the whole edit.
if (idFieldValue == null || glAccountId == null) {
continue;
}
final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null;
final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null;
out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId));
@@ -635,15 +669,22 @@ public final class ProductPendingEditFieldMapper {
return true;
}
private static Long longOrNull(final JsonObject o, final String fieldName) {
final com.google.gson.JsonElement el = o.get(fieldName);
return el == null || el.isJsonNull() ? null : el.getAsLong();
}
public static void writeGlArrayMappingsToJson(final JsonObject root, final List<ProductPendingEditGlMapping> mappings) {
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings",
"paymentTypeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId");
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD);
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId",
INCOME_ACCOUNT_GL_FIELD);
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId",
INCOME_ACCOUNT_GL_FIELD);
}
private static void writeGlArrayGroup(final JsonObject root, final List<ProductPendingEditGlMapping> mappings, final String type,
final String paramName, final String idFieldName) {
final String paramName, final String idFieldName, final String glFieldName) {
final JsonArray array = new JsonArray();
for (final ProductPendingEditGlMapping mapping : mappings) {
if (!type.equals(mapping.getMappingType())) {
@@ -652,7 +693,7 @@ public final class ProductPendingEditFieldMapper {
final JsonObject entry = new JsonObject();
final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId();
entry.addProperty(idFieldName, idFieldValue);
entry.addProperty("glAccountId", mapping.getGlAccountId());
entry.addProperty(glFieldName, mapping.getGlAccountId());
array.add(entry);
}
root.add(paramName, array);
@@ -75,9 +75,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc = false;
@@ -138,9 +135,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom<Long
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
@@ -162,7 +156,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom<Long
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
}
@@ -134,9 +134,6 @@ public class SavingsAccountPendingEdit extends AbstractPersistableCustom<Long> {
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc;
@@ -82,6 +82,11 @@ public class SavingsAccountRepositoryWrapper {
return account;
}
@Transactional(readOnly = true)
public boolean existsWithDepositAccountType(final Long savingsId, final DepositAccountType depositAccountType) {
return this.repository.findByIdAndDepositAccountType(savingsId, depositAccountType.getValue()) != null;
}
@Transactional(readOnly = true)
public List<SavingsAccount> findSavingAccountByClientId(@Param("clientId") Long clientId) {
List<SavingsAccount> accounts = this.repository.findSavingAccountByClientId(clientId);
@@ -126,6 +126,12 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat
@Column(name = "reason_for_block", nullable = true)
private String reasonForBlock;
/** {@link SavingsAccount#HOLD_CATEGORY_CASH} or {@link SavingsAccount#HOLD_CATEGORY_CLEARING} --
* only meaningful on a hold-amount transaction; carried here (rather than looked up) so a later
* release can credit the same running total the hold itself debited. */
@Column(name = "hold_category", length = 20, nullable = true)
private String holdCategory;
@Column(name = "is_reversal", nullable = false)
private boolean reversalTransaction;
@@ -459,6 +465,14 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat
this.reasonForBlock = reasonForBlock;
}
public String getHoldCategory() {
return this.holdCategory;
}
public void updateHoldCategory(String holdCategory) {
this.holdCategory = holdCategory;
}
public Long getReleaseIdOfHoldAmountTransaction() {
return this.releaseIdOfHoldAmountTransaction;
}
@@ -36,6 +36,22 @@ public class SavingsAccountTransactionComparator implements Comparator<SavingsAc
if (result != 0) {
return result;
}
// For same date and same createdDate: interest postings must always be processed
// before withdrawals/debits. This is critical for Special FD (Non-Cumulative)
// accounts where the interest-transfer sweep withdrawal carries the same
// transaction_date as the interest posting it pays out. When the original posting
// is reversed and a new one is created the new posting gets a higher ID than the
// old withdrawal. Without this tiebreaker the comparator falls through to
// ID-order, placing the withdrawal before the new posting and causing the running
// balance to dip incorrectly (e.g. ₹99,901.39 instead of ₹100,000.00).
boolean o1IsPosting = o1.isInterestPostingAndNotReversed();
boolean o2IsPosting = o2.isInterestPostingAndNotReversed();
if (o1IsPosting && !o2IsPosting) {
return -1; // interest posting before withdrawal/debit
}
if (!o1IsPosting && o2IsPosting) {
return 1; // withdrawal/debit after interest posting
}
if (o1.getId() != null && o2.getId() != null) {
return o1.getId().compareTo(o2.getId());
}
@@ -374,8 +374,6 @@ public class SavingsProductAssembler {
facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ?
facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ?
facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ?
facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ?
@@ -75,9 +75,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc = false;
@@ -138,9 +135,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom<Long
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
@@ -163,7 +157,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom<Long
this.neftFacility = other.getNeftFacility();
this.rtgsFacility = other.getRtgsFacility();
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
this.withdrawalFacility = other.getWithdrawalFacility();
this.depositFacilityKyc = other.getDepositFacilityKyc();
this.virtualDebitCard = other.getVirtualDebitCard();
}
@@ -0,0 +1,28 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class FacilityChargeMappingNotFoundException extends AbstractPlatformResourceNotFoundException {
public FacilityChargeMappingNotFoundException(final Long id) {
super("error.msg.facilitychargemapping.id.invalid", "Facility charge mapping with identifier " + id + " does not exist", id);
}
}
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.savings.service;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.office.domain.Office;
@@ -43,6 +45,8 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult calculateInterest(Long savingsId, DepositAccountType depositAccountType);
List<Map<String, Object>> retrieveProjectedInterestSchedule(Long savingsId, DepositAccountType depositAccountType);
CommandProcessingResult postInterest(Long savingsId, DepositAccountType depositAccountType);
CommandProcessingResult undoFDTransaction(Long savingsId, Long transactionId, boolean allowAccountTransferModification);
@@ -61,6 +65,8 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult prematureCloseRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult processFdPayment(Long savingsId, JsonCommand command);
SavingsAccountTransaction initiateSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);
SavingsAccountTransaction withdrawSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);
@@ -25,6 +25,7 @@ import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.charge.domain.Charge;
@@ -80,7 +81,6 @@ public class FacilityChargeAutoApplyService {
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
@@ -117,18 +117,13 @@ public class FacilityChargeAutoApplyService {
public List<SavingsAccountCharge> applyChargesForFacilities(final SavingsAccount account, final java.util.Set<String> facilityTypes) {
final List<SavingsAccountCharge> chargesToApply = new ArrayList<>();
for (final String facilityType : facilityTypes) {
mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> {
final Charge charge = mapping.getCharge();
if (charge == null) {
return;
}
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(),
null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
chargesToApply.add(accountCharge);
});
final Charge charge = requireMappedCharge(facilityType);
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(),
null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
chargesToApply.add(accountCharge);
}
if (!chargesToApply.isEmpty()) {
savingsAccountChargeRepository.saveAll(chargesToApply);
@@ -160,7 +155,11 @@ public class FacilityChargeAutoApplyService {
}
}
if (!transactions.isEmpty()) {
savingsAccountTransactionRepository.saveAll(transactions);
// Flush immediately (not just save) so each transaction gets its DB-generated id right
// away -- the caller posts GL journal entries for these transactions straight after this
// call returns, and that requires a real id on each one, not one that only appears at
// end-of-transaction commit.
savingsAccountTransactionRepository.saveAllAndFlush(transactions);
account.recalculateBalancesAfterFacilityCharges(transactionDate);
}
return transactions;
@@ -215,25 +214,49 @@ public class FacilityChargeAutoApplyService {
&& c.getCharge().getName().equals(getChargeName(facilityType)));
if (alreadyApplied) return;
mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> {
final Charge charge = mapping.getCharge();
if (charge == null) return;
final Charge charge = requireMappedCharge(facilityType);
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(
charge,
charge.getAmount(),
null, // chargeTime derived from Charge definition
null, // chargeCalculation derived from Charge definition
resolveDueDate(charge),
true,
feeOnMonthDay,
charge.getFeeInterval());
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(
charge,
charge.getAmount(),
null, // chargeTime derived from Charge definition
null, // chargeCalculation derived from Charge definition
resolveDueDate(charge),
true,
feeOnMonthDay,
charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
collector.add(accountCharge);
});
accountCharge.update(account);
account.charges().add(accountCharge);
collector.add(accountCharge);
}
/**
* Looks up the charge mapped to {@code facilityType}, throwing rather than silently letting the facility be
* enabled with no charge (or a charge that no longer exists) if the mapping is missing, has no charge
* configured, or its charge has since been deleted/deactivated. Configure it via Products {@literal >}
* Facility Charge Mappings first.
*/
private Charge requireMappedCharge(final String facilityType) {
final FacilityChargeMapping mapping = mappingRepository.findByFacilityType(facilityType).orElse(null);
// An inactive mapping is treated the same as an unmapped one: the Facility Charge Mappings screen's
// "Active" toggle is how an admin marks a mapping as genuinely configured, so a charge sitting on an
// inactive row must not silently apply.
if (mapping == null || mapping.getCharge() == null || !Boolean.TRUE.equals(mapping.getIsActive())) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped",
"A charge has not been mapped for the '" + facilityType
+ "' facility. Configure it under Products > Facility Charge Mappings before enabling it.",
facilityType);
}
final Charge charge = mapping.getCharge();
if (charge.isDeleted() || !charge.isActive()) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted",
"The charge mapped to the '" + facilityType
+ "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.",
facilityType);
}
return charge;
}
private MonthDay resolveFeeOnMonthDay(final Charge charge) {
@@ -281,7 +304,6 @@ public class FacilityChargeAutoApplyService {
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
if (!chargesToApply.isEmpty()) {
@@ -0,0 +1,32 @@
/**
* 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.service;
import java.util.List;
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData;
public interface FacilityChargeMappingReadPlatformService {
List<FacilityChargeMappingData> retrieveAll();
FacilityChargeMappingData retrieveOne(Long id);
/** All active charges, for the admin screen's "change the mapped charge" dropdown. */
FacilityChargeMappingData retrieveTemplate();
}
@@ -0,0 +1,78 @@
/**
* 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.service;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.service.ChargeReadPlatformService;
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData;
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData.FacilityChargeOptionData;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository;
import org.apache.fineract.portfolio.savings.exception.FacilityChargeMappingNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FacilityChargeMappingReadPlatformServiceImpl implements FacilityChargeMappingReadPlatformService {
private final FacilityChargeMappingRepository facilityChargeMappingRepository;
private final ChargeReadPlatformService chargeReadPlatformService;
@Override
public List<FacilityChargeMappingData> retrieveAll() {
return facilityChargeMappingRepository.findAll().stream().sorted(Comparator.comparing(FacilityChargeMapping::getFacilityType))
.map(this::toData).toList();
}
@Override
public FacilityChargeMappingData retrieveOne(final Long id) {
final FacilityChargeMapping mapping = facilityChargeMappingRepository.findById(id)
.orElseThrow(() -> new FacilityChargeMappingNotFoundException(id));
return toData(mapping);
}
@Override
public FacilityChargeMappingData retrieveTemplate() {
final FacilityChargeMappingData template = new FacilityChargeMappingData();
template.setChargeOptions(chargeReadPlatformService.retrieveAllCharges().stream()
.map(charge -> new FacilityChargeOptionData(charge.getId(), charge.getName(), charge.getAmount())).toList());
return template;
}
private FacilityChargeMappingData toData(final FacilityChargeMapping mapping) {
final Charge charge = mapping.getCharge();
final boolean chargeAvailable = charge == null || (!charge.isDeleted() && charge.isActive());
// FacilityChargeAutoApplyService always charges charge.getAmount() (the charge's own, live amount) --
// mapping.getDefaultAmount() is a separate, unrelated stored column that nothing in the actual charging
// path reads, so showing it here would silently drift from what an account is really charged the moment
// someone edits the charge itself. Show the charge's real amount instead; edits to it happen on the
// Charge (Products > Charges), not here.
// When no charge is mapped, mapping.getChargeName()/getDefaultAmount() are stale leftovers from the
// pre-seed migration -- report null so the screen shows the facility as genuinely unmapped, not as
// if a charge (with a name and amount) were already chosen.
final String chargeName = charge != null ? charge.getName() : null;
final java.math.BigDecimal amount = charge != null ? charge.getAmount() : null;
return new FacilityChargeMappingData(mapping.getId(), mapping.getFacilityType(), charge == null ? null : charge.getId(),
chargeName, amount, mapping.getChargeTimeType(), mapping.getChargeAppliesTo(), mapping.getIsActive(),
chargeAvailable);
}
}
@@ -0,0 +1,27 @@
/**
* 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.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface FacilityChargeMappingWritePlatformService {
CommandProcessingResult updateMapping(Long id, JsonCommand command);
}
@@ -0,0 +1,102 @@
/**
* 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.service;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
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.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository;
import org.apache.fineract.portfolio.savings.exception.FacilityChargeMappingNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class FacilityChargeMappingWritePlatformServiceImpl implements FacilityChargeMappingWritePlatformService {
private static final String CHARGE_ID_PARAM = "chargeId";
private static final String IS_ACTIVE_PARAM = "isActive";
private final FacilityChargeMappingRepository facilityChargeMappingRepository;
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
@Transactional
@Override
public CommandProcessingResult updateMapping(final Long id, final JsonCommand command) {
final FacilityChargeMapping mapping = facilityChargeMappingRepository.findById(id)
.orElseThrow(() -> new FacilityChargeMappingNotFoundException(id));
final Map<String, Object> changes = new HashMap<>();
final Long existingChargeId = mapping.getCharge() == null ? null : mapping.getCharge().getId();
if (command.isChangeInLongParameterNamed(CHARGE_ID_PARAM, existingChargeId)) {
final Long newChargeId = command.longValueOfParameterNamed(CHARGE_ID_PARAM);
final Charge charge = chargeRepositoryWrapper.findOneWithNotFoundDetection(newChargeId);
// Each facility's charge must be its own: FacilityChargeAutoApplyService#applyIfEnabled dedups a
// newly-enabled facility's charge by matching an existing SavingsAccountCharge's charge NAME, so if
// two facilities shared the same charge, enabling the second one on an account that already has the
// first would be silently skipped as "already applied" -- under-charging the account by one facility
// worth of fee. Reject the mapping change here instead of letting that surface downstream.
final Optional<FacilityChargeMapping> conflict = facilityChargeMappingRepository.findAllByIsActiveTrue().stream()
.filter(other -> !other.getId().equals(mapping.getId())) //
.filter(other -> other.getCharge() != null && newChargeId.equals(other.getCharge().getId())) //
.findFirst();
if (conflict.isPresent()) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.already.mapped",
"This charge is already mapped to the '" + conflict.get().getFacilityType()
+ "' facility. Each charge can only be mapped to one facility, to avoid a shared charge being"
+ " under-applied when both facilities are enabled on the same account.",
conflict.get().getFacilityType());
}
mapping.setCharge(charge);
mapping.setChargeName(charge.getName());
// Keep the stored column in step with the charge actually being applied, even though nothing
// currently reads it directly (the read side reports charge.getAmount() live) -- avoids a future
// reader finding a stale value here.
mapping.setDefaultAmount(charge.getAmount());
changes.put(CHARGE_ID_PARAM, newChargeId);
}
if (command.isChangeInBooleanParameterNamed(IS_ACTIVE_PARAM, mapping.getIsActive())) {
final Boolean newIsActive = command.booleanObjectValueOfParameterNamed(IS_ACTIVE_PARAM);
mapping.setIsActive(newIsActive);
changes.put(IS_ACTIVE_PARAM, newIsActive);
}
if (!changes.isEmpty()) {
facilityChargeMappingRepository.saveAndFlush(mapping);
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(id) //
.with(changes) //
.build();
}
}
@@ -18,29 +18,43 @@
*/
package org.apache.fineract.portfolio.savings.service;
import jakarta.annotation.PostConstruct;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.annotation.DependsOn;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Seeds default facility charges on service startup via JDBC.
* For each facility type (ATM, UPI, NEFT etc.), ensures a charge exists in m_charge
* and a mapping exists in m_facility_charge_mapping.
* Bank staff can change amounts from the Charges admin screen later.
* Seeder only creates records that are missing — safe to run repeatedly.
* Seeds default facility charges on service startup via JDBC, one per facility type (ATM, UPI,
* NEFT etc.), and points each facility's m_facility_charge_mapping row at its own default charge,
* so the Facility Charge Mappings admin screen comes up correctly configured on any install rather
* than showing an unmapped dash on every row.
* <p>
* Mappings are seeded INACTIVE on purpose. Activating a mapping is what actually starts charging
* the customer, so that stays an explicit admin decision made through that screen —
* FacilityChargeAutoApplyService treats an inactive mapping exactly like an unmapped one and blocks
* the facility from being enabled until an admin flips Active. Seeding the charge only removes the
* tedium of picking the obvious one; it does not pre-approve any billing.
* <p>
* Every step is guarded (charges created only when missing, mappings linked only while still
* unmapped), so an admin's own choice of charge or amount is never overwritten and the seeder is
* safe to run repeatedly and against existing databases.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FacilityChargeSeederService {
@DependsOn({ "tenantDetailsService", "tenantDatabaseUpgradeService" })
public class FacilityChargeSeederService implements InitializingBean {
private final JdbcTemplate jdbcTemplate;
private final TenantDetailsService tenantDetailsService;
// facilityType → [chargeName, defaultAmount, chargeTimeType, chargeAppliesTo]
// chargeTimeType: 3=SAVINGS_ACTIVATION, 6=ANNUAL_FEE, 7=MONTHLY_FEE
@@ -62,18 +76,52 @@ public class FacilityChargeSeederService {
FACILITY_DEFAULTS.put("NEFT_FACILITY", new Object[]{"NEFT Facility Charge", 5.00, 3, 2});
FACILITY_DEFAULTS.put("RTGS_FACILITY", new Object[]{"RTGS Facility Charge", 25.00, 3, 2});
FACILITY_DEFAULTS.put("INTER_BANK_TOTAL_CAP", new Object[]{"Inter-Bank Cap Charge", 0.00, 3, 2});
FACILITY_DEFAULTS.put("WITHDRAWAL_FACILITY", new Object[]{"Withdrawal Facility Charge", 0.00, 3, 2});
FACILITY_DEFAULTS.put("DEPOSIT_FACILITY_KYC", new Object[]{"Deposit KYC Charge", 0.00, 3, 2});
FACILITY_DEFAULTS.put("VIRTUAL_DEBIT_CARD", new Object[]{"Virtual Debit Card Charge", 199.00, 3, 2});
}
@PostConstruct
@Transactional
/**
* Runs once per tenant after the tenant datasource and Liquibase migrations are ready.
* <p>
* This deliberately does NOT use {@code @PostConstruct}: that fires while the bean graph is still
* being built, before Fineract's per-tenant routing datasource is available, so every tenant table
* lookup failed with "bad SQL grammar" and the seeder silently skipped on every startup. Seeding
* has to happen after {@code tenantDatabaseUpgradeService} has migrated the schema, with the tenant
* bound to the thread so the routing datasource resolves to that tenant's database.
*/
@Override
public void afterPropertiesSet() {
final List<FineractPlatformTenant> tenants = tenantDetailsService.findAllTenants();
if (tenants == null || tenants.isEmpty()) {
log.warn("FacilityChargeSeeder: no tenants found, skipping.");
return;
}
final FineractPlatformTenant originalTenant = ThreadLocalContextUtil.getTenant();
try {
for (final FineractPlatformTenant tenant : tenants) {
try {
ThreadLocalContextUtil.setTenant(tenant);
seedFacilityCharges();
} catch (Exception e) {
// Seeding defaults must never take the platform down for one bad tenant.
log.warn("FacilityChargeSeeder: skipped for tenant '{}' — {}", tenant.getTenantIdentifier(),
e.getMessage(), e);
}
}
} finally {
ThreadLocalContextUtil.setTenant(originalTenant);
}
}
/**
* Not {@code @Transactional}: this is invoked directly from {@link #afterPropertiesSet()} on the same
* bean, so Spring's proxy would be bypassed and the annotation would be a false promise. It needs no
* transaction anyway — every statement is individually guarded and idempotent, so a partial run simply
* completes on the next startup.
*/
public void seedFacilityCharges() {
try {
// Find an income GL account to link charges (prefer DETAIL type income account)
Long incomeGlAccountId = findIncomeGlAccountId();
for (Map.Entry<String, Object[]> entry : FACILITY_DEFAULTS.entrySet()) {
final String facilityType = entry.getKey();
final Object[] def = entry.getValue();
@@ -82,78 +130,77 @@ public class FacilityChargeSeederService {
final int chargeTimeType = (int) def[2];
final int chargeAppliesTo = (int) def[3];
// 1. Check if mapping row exists and already has a charge linked
List<Long> existingChargeId = jdbcTemplate.queryForList(
"SELECT charge_id FROM m_facility_charge_mapping WHERE facility_type = ? AND charge_id IS NOT NULL",
Long.class, facilityType);
if (!existingChargeId.isEmpty()) {
continue; // Already seeded
}
// 2. Check if charge with this name already exists in m_charge
// 1. Ensure a default charge exists in m_charge for this facility (creates it if missing) —
// purely so it's available as a ready-made option in the mapping screen's charge dropdown.
List<Long> chargeIds = jdbcTemplate.queryForList(
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false LIMIT 1",
Long.class, chargeName);
Long chargeId;
if (chargeIds.isEmpty()) {
// 3. Insert charge into m_charge
// income_or_liability_account_id deliberately left NULL: Fineract's own
// accounting engine (AccountingProcessorHelper.getLinkedGLAccountForSavingsCharges)
// uses a charge's own GL account, when set, ahead of the savings PRODUCT's own
// "Income From Fees" mapping. Setting one here would silently override every
// product's own configuration with the same shared account for all 16 facilities
// -- exactly what happened before this fix, all landing on whichever income
// account findIncomeGlAccountId() happened to find first. Leaving it unset lets
// each product's own accounting setup decide where this income posts, which is
// the actual point of a per-product "Income From Fees" mapping.
jdbcTemplate.update(
"INSERT INTO m_charge (name, currency_code, amount, charge_applies_to_enum, "
+ "charge_time_enum, charge_calculation_enum, is_penalty, is_active, is_deleted, "
+ "is_free_withdrawal, is_payment_type, income_or_liability_account_id) "
+ "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false, ?)",
chargeName, amount, chargeAppliesTo, chargeTimeType, incomeGlAccountId);
+ "is_free_withdrawal, is_payment_type) "
+ "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false)",
chargeName, amount, chargeAppliesTo, chargeTimeType);
chargeId = jdbcTemplate.queryForObject(
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false ORDER BY id DESC LIMIT 1",
Long.class, chargeName);
log.info("FacilityChargeSeeder: inserted charge '{}' id={}", chargeName, chargeId);
} else {
chargeId = chargeIds.get(0);
log.info("FacilityChargeSeeder: inserted charge '{}'", chargeName);
}
// 4. Update or insert mapping row
int updated = jdbcTemplate.update(
"UPDATE m_facility_charge_mapping SET charge_id = ? WHERE facility_type = ?",
chargeId, facilityType);
// 2. Ensure the facility_charge_mapping row itself exists so the admin screen lists every
// facility type. Inserted inactive and unmapped; step 3 below fills in the charge.
List<Long> existingMapping = jdbcTemplate.queryForList(
"SELECT id FROM m_facility_charge_mapping WHERE facility_type = ?", Long.class, facilityType);
if (updated == 0) {
if (existingMapping.isEmpty()) {
jdbcTemplate.update(
"INSERT INTO m_facility_charge_mapping "
+ "(facility_type, charge_id, charge_name, default_amount, charge_time_type, charge_applies_to, is_active) "
+ "VALUES (?, ?, ?, ?, ?, ?, 1)",
facilityType, chargeId, chargeName, amount, chargeTimeType, chargeAppliesTo);
+ "VALUES (?, NULL, ?, ?, ?, ?, false)",
facilityType, chargeName, amount, chargeTimeType, chargeAppliesTo);
log.info("FacilityChargeSeeder: added unmapped facility row '{}'", facilityType);
}
log.info("FacilityChargeSeeder: mapped facility '{}' → charge id={}", facilityType, chargeId);
// 3. Point each still-unmapped facility at its own default charge (ATM_WITHDRAWAL ->
// "ATM Withdrawal Charge", ...) so a fresh install shows the correct charge on every row
// instead of a dash, on any machine, without an admin re-picking all 15 by hand.
//
// is_active is deliberately left false: activating the mapping is what actually starts
// billing the customer, and that must stay an explicit admin decision made through the
// Facility Charge Mappings screen (FacilityChargeAutoApplyService.requireMappedCharge
// treats an inactive mapping exactly like an unmapped one, so nothing is charged until
// an admin flips Active). This only removes the tedium of choosing the obvious charge,
// not the approval step.
//
// Scoped to charge_id IS NULL so a mapping or amount an admin has already set is never
// overwritten -- that makes this safe to re-run on every startup and on existing databases.
final int linked = jdbcTemplate.update(
"UPDATE m_facility_charge_mapping SET charge_id = "
+ "(SELECT id FROM m_charge WHERE name = ? AND is_deleted = false ORDER BY id LIMIT 1) "
+ "WHERE facility_type = ? AND charge_id IS NULL",
chargeName, facilityType);
if (linked > 0) {
log.info("FacilityChargeSeeder: mapped facility '{}' to charge '{}' (left inactive"
+ " -- an admin must activate it before it applies)", facilityType, chargeName);
}
}
log.info("FacilityChargeSeeder: seeding complete.");
} catch (Exception e) {
log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage());
}
}
private Long findIncomeGlAccountId() {
try {
// Prefer "INCOME FROM ALL" GL account for facility charges
List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND account_usage = 1 "
+ "AND disabled = false AND name = 'INCOME FROM ALL' LIMIT 1",
Long.class);
if (!ids.isEmpty()) return ids.get(0);
// Fallback: first available income detail account
ids = jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND account_usage = 1 "
+ "AND disabled = false ORDER BY id ASC LIMIT 1",
Long.class);
return ids.isEmpty() ? null : ids.get(0);
} catch (Exception e) {
return null;
// Log the cause, not just the message: this swallowed a startup-ordering failure silently
// for a long time because the underlying SQLException never reached the log.
log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage(), e);
}
}
}
@@ -79,6 +79,9 @@ public interface SavingsAccountWritePlatformService {
void processPostActiveActions(SavingsAccount account, DateTimeFormatter fmt, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds);
void postJournalEntriesForSavingsAccount(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds, boolean backdatedTxnsAllowedTill);
CommandProcessingResult modifyWithHoldTax(Long savingsAccountId, JsonCommand command);
void setSubStatusInactive(Long savingsId);
@@ -1,56 +1,58 @@
<?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">
<!-- Sequence is starting from 2000 to make it easier to move existing liquibase changesets here -->
<include file="parts/2001_add_savings_accrual_job.xml" relativeToChangelogFile="true" />
<include file="parts/2002_add_savings_accrual_permission.xml" relativeToChangelogFile="true" />
<include file="parts/2003_add_accrued_till_date_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2004_add_savings_account_cob_infrastructure.xml" relativeToChangelogFile="true" />
<include file="parts/2005_add_savings_product_classification_code_values.xml" relativeToChangelogFile="true" />
<include file="parts/2006_add_period_frequency_options.xml" relativeToChangelogFile="true" />
<include file="parts/2007_add_interest_calculation_type_option.xml" relativeToChangelogFile="true" />
<include file="parts/2008_add_other_gl_mappings_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2009_add_gl_account_id_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2010_add_savings_account_cbs_fields.xml" relativeToChangelogFile="true" />
<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" />
<include file="parts/2015_add_senior_citizen_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2016_add_share_linking_enabled_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2017_add_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2018_fix_saving_product_temp_lock_constraint.xml" relativeToChangelogFile="true" />
<include file="parts/2019_add_command_source_link_to_saving_product_temp.xml" relativeToChangelogFile="true" />
<include file="parts/2020_generalize_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2021_add_fd_rd_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2022_add_product_checker_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2023_fix_product_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2025_recreate_savings_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2026_add_pending_edit_typed_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2027_add_pending_edit_satellite_tables.xml" relativeToChangelogFile="true" />
<include file="parts/2028_update_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2029_remove_proposed_payload_json.xml" relativeToChangelogFile="true" />
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
<?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">
<!-- Sequence is starting from 2000 to make it easier to move existing liquibase changesets here -->
<include file="parts/2001_add_savings_accrual_job.xml" relativeToChangelogFile="true" />
<include file="parts/2002_add_savings_accrual_permission.xml" relativeToChangelogFile="true" />
<include file="parts/2003_add_accrued_till_date_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2004_add_savings_account_cob_infrastructure.xml" relativeToChangelogFile="true" />
<include file="parts/2005_add_savings_product_classification_code_values.xml" relativeToChangelogFile="true" />
<include file="parts/2006_add_period_frequency_options.xml" relativeToChangelogFile="true" />
<include file="parts/2007_add_interest_calculation_type_option.xml" relativeToChangelogFile="true" />
<include file="parts/2008_add_other_gl_mappings_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2009_add_gl_account_id_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2010_add_savings_account_cbs_fields.xml" relativeToChangelogFile="true" />
<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" />
<include file="parts/2015_add_senior_citizen_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2016_add_share_linking_enabled_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2017_add_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2018_fix_saving_product_temp_lock_constraint.xml" relativeToChangelogFile="true" />
<include file="parts/2019_add_command_source_link_to_saving_product_temp.xml" relativeToChangelogFile="true" />
<include file="parts/2020_generalize_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2021_add_fd_rd_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2022_add_product_checker_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2023_fix_product_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2025_recreate_savings_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2026_add_pending_edit_typed_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2027_add_pending_edit_satellite_tables.xml" relativeToChangelogFile="true" />
<include file="parts/2028_update_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2029_remove_proposed_payload_json.xml" relativeToChangelogFile="true" />
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2032_add_share_account_id_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2032_remove_withdrawal_facility.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -188,4 +188,21 @@
);
</sql>
</changeSet>
<changeSet author="fineract" id="2005-8-savings-product-account-type-add-normal-saving-account">
<sql>
INSERT INTO m_code (code_name, is_system_defined)
SELECT 'SavingsProductAccountType', TRUE
WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsProductAccountType');
INSERT INTO m_code_value (code_id, code_value, order_position, code_description, is_active, is_mandatory)
SELECT c.id, 'Normal Saving Account', 4, 'Normal Saving Account', TRUE, FALSE
FROM m_code c
WHERE c.code_name = 'SavingsProductAccountType'
AND NOT EXISTS (
SELECT 1 FROM m_code_value cv
WHERE cv.code_id = c.id AND cv.code_value = 'Normal Saving Account'
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -293,4 +293,52 @@
</sql>
</changeSet>
<!--
Clear the income GL these charges were seeded with, and let each product's own mapping decide.
Every insert above picks its income account with
SELECT id FROM acc_gl_account
WHERE classification_enum = 4 AND account_usage = 1 AND disabled = false
ORDER BY id LIMIT 1
which is "whichever income account happens to have the lowest id". That is not a choice, it is an
accident of the order the chart of accounts was created in, and it lands somewhere different on
every database: on one tenant it resolved to 400101 INCOME FROM LOAN, so savings facility charges
were booked as loan income; on a tenant whose chart was seeded in another order it resolved to
400103 DD Issuance Charges Income, so an ATM charge was booked against demand drafts. Neither is
the account a bank would name for an ATM fee, and no screen shows the reader which one was picked.
A charge's own account overrides the product's Income from Fees mapping — see
AccountingProcessorHelper.getLinkedGLAccountForSavingsCharges, which reads the charge's account
first and only falls back to the product mapping when it is null. So while these values stand, the
account an administrator chose on the product's Accounting tab is silently ignored. Setting them
null restores that mapping as the answer, which is where a bank configures this and the only place
it can be seen and changed.
Only rows still carrying the seeded value are touched: the account is compared against what that
same query resolves to now, so a charge an administrator has since pointed at a real account keeps
it. 0500_reset_facility_charge_mappings_to_unmapped.xml already decided these seeds are not an
administrator's choice and deactivated the mappings for the same reason.
-->
<changeSet id="2011-19" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_charge"/>
</preConditions>
<sql>
UPDATE m_charge
SET income_or_liability_account_id = NULL
WHERE income_or_liability_account_id = (
SELECT id FROM acc_gl_account
WHERE classification_enum = 4 AND account_usage = 1 AND disabled = false
ORDER BY id LIMIT 1)
AND name IN ('ATM Facility Charge', 'ATM Withdrawal Charge', 'UPI Facility Charge',
'POS Transaction Charge', 'Cheque Book Charge', 'IMPS Facility Charge',
'SMS Alert Monthly Charge', 'BBPS Facility Charge', 'Insurance Cover Charge',
'BEPG Facility Charge', 'NEFT Facility Charge', 'RTGS Facility Charge',
'Inter-Bank Cap Charge', 'Withdrawal Facility Charge', 'Deposit KYC Charge',
'Virtual Debit Card Charge');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?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">
<!-- The reverse of m_share_account.savings_account_id (0489-era, already existed): this is the
savings account's own pointer back to the share account it was linked to at creation, when
Share Linking Enabled was set. No FK to m_share_account here - fineract-savings has no
reference to that table's owning module, so the write side is responsible for validating
this id and for keeping the two ends of the link in sync. -->
<changeSet id="2032_add_share_account_id_to_savings_account" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_account"/>
<not><columnExists tableName="m_savings_account" columnName="share_account_id"/></not>
</preConditions>
<addColumn tableName="m_savings_account">
<column name="share_account_id" type="BIGINT"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,101 @@
<?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">
<!--
Retires the "Withdrawal Facility" account feature and its auto-applied charge.
The facility flag is gone from the product/account APIs and UI, so the charge can no
longer be switched on for anything new. This changeset stops it billing going forward
and hides it from charge pickers, while leaving already-posted transactions intact so
historical statements and the trial balance still reconcile.
The withdrawal_facility / fc_withdrawal_facility columns are deliberately NOT dropped:
they are simply no longer mapped or read. Dropping them would be irreversible and buys
nothing, and the historical changesets that created them stay untouched so Liquibase
checksums keep validating.
-->
<!-- Step 1: unmap the facility type so the seeder/auto-apply can never resolve a charge -->
<changeSet id="2032-1" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_facility_charge_mapping" />
</preConditions>
<sql>
DELETE FROM m_facility_charge_mapping WHERE facility_type = 'WITHDRAWAL_FACILITY';
</sql>
</changeSet>
<!-- Step 2: drop pending (unpaid, unwaived) withdrawal-facility charges from savings accounts.
Paid or waived rows are left alone so posted accounting entries stay balanced. -->
<changeSet id="2032-2" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_account_charge" />
</preConditions>
<sql>
DELETE FROM m_savings_account_charge
WHERE charge_id IN (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge')
AND COALESCE(is_paid_derived, false) = false
AND COALESCE(waived, false) = false;
</sql>
</changeSet>
<!-- Step 3: soft-delete + deactivate the charge definition so it disappears from
charge dropdowns without breaking FKs from any historical charge rows. -->
<changeSet id="2032-3" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = false</sqlCheck>
</preConditions>
<sql>
UPDATE m_charge SET is_active = false, is_deleted = true
WHERE name = 'Withdrawal Facility Charge';
</sql>
</changeSet>
<!-- Step 4: stop the flag driving anything if a stale row is still set -->
<changeSet id="2032-4" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_savings_account_facility_config" />
<columnExists tableName="m_savings_account_facility_config" columnName="withdrawal_facility" />
</and>
</preConditions>
<sql>
UPDATE m_savings_account_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true;
</sql>
</changeSet>
<changeSet id="2032-5" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_savings_product_facility_config" />
<columnExists tableName="m_savings_product_facility_config" columnName="withdrawal_facility" />
</and>
</preConditions>
<sql>
UPDATE m_savings_product_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true;
</sql>
</changeSet>
</databaseChangeLog>