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:
+23
@@ -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() {
|
||||
//
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -177,6 +177,10 @@ public class InterestRateChartSlabFields {
|
||||
return isGreater;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return this.description;
|
||||
}
|
||||
|
||||
public Integer periodType() {
|
||||
return this.periodType;
|
||||
}
|
||||
|
||||
+17
-5
@@ -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;
|
||||
|
||||
+83
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
|
||||
+23
-12
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+8
-8
@@ -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)
|
||||
|
||||
+10
@@ -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);
|
||||
|
||||
+158
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+89
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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();
|
||||
|
||||
+72
@@ -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,
|
||||
|
||||
-3
@@ -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;
|
||||
|
||||
|
||||
+36
@@ -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;
|
||||
}
|
||||
|
||||
+57
-16
@@ -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);
|
||||
|
||||
+4794
-4382
File diff suppressed because it is too large
Load Diff
-7
@@ -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());
|
||||
}
|
||||
|
||||
-3
@@ -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;
|
||||
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+14
@@ -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;
|
||||
}
|
||||
|
||||
+16
@@ -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());
|
||||
}
|
||||
|
||||
-2
@@ -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") ?
|
||||
|
||||
-7
@@ -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();
|
||||
}
|
||||
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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);
|
||||
|
||||
+54
-32
@@ -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()) {
|
||||
|
||||
+32
@@ -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();
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+102
@@ -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();
|
||||
}
|
||||
}
|
||||
+112
-65
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+58
-56
@@ -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>
|
||||
|
||||
+17
@@ -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>
|
||||
|
||||
+48
@@ -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>
|
||||
|
||||
+28
@@ -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>
|
||||
+101
@@ -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>
|
||||
Reference in New Issue
Block a user