Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d10ec60f8 | ||
|
|
14973dfd5e | ||
|
|
a127710442 | ||
|
|
919bfb5204 | ||
|
|
39e3fcec59 | ||
|
|
cca4ae583f | ||
|
|
99a7e04872 | ||
|
|
643427bd1a | ||
|
|
6c7c0f8445 | ||
|
|
88157ee9d6 | ||
|
|
760fff8d3e | ||
|
|
b726577031 | ||
|
|
d1e94fd6a3 | ||
|
|
22c4403a14 |
+1
-1
@@ -154,7 +154,7 @@ gitVersioning.apply {
|
||||
}
|
||||
}
|
||||
rev {
|
||||
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
|
||||
version = '1.15.0-SNAPSHOT'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-10
@@ -137,16 +137,7 @@ public class CommandSourceService {
|
||||
String permission = commandSource.getPermissionCode();
|
||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||
// isApprovedByChecker is only ever true here for a genuine checker approval
|
||||
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
|
||||
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
|
||||
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
|
||||
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
|
||||
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
|
||||
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
|
||||
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
|
||||
// auto-approve their own submission just by holding it.
|
||||
if (isApprovedByChecker) {
|
||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||
commandSource.markAsChecked(user);
|
||||
} else {
|
||||
if (commandSource.isSanitized()) {
|
||||
@@ -160,6 +151,28 @@ public class CommandSourceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||
if (!user.isCheckerSuperUser()) {
|
||||
return false;
|
||||
}
|
||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "DELINQUENCY_BUCKET".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "DELINQUENCY_RANGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||
return;
|
||||
|
||||
+8
@@ -2299,6 +2299,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createLoanBaseInterestRate() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "LOANBASEINTERESTRATE";
|
||||
this.entityId = null;
|
||||
this.href = "/loanproducts/baseinterestrates";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||
|
||||
+8
-20
@@ -48,12 +48,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final CommandProcessingService processAndLogCommandService;
|
||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
||||
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
|
||||
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
|
||||
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
|
||||
// live, independently-configurable global switch (System -> Configurations); deleting this field,
|
||||
// and by extension deciding that switch should stop existing altogether, is a separate decision
|
||||
// from closing this specific self-approval loophole.
|
||||
private final ConfigurationDomainService configurationService;
|
||||
|
||||
@Override
|
||||
@@ -129,20 +123,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
AppUser appUser = this.context.authenticatedUser();
|
||||
String permissionCode = commandSource.getPermissionCode();
|
||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
||||
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
||||
// permission still lets its holder action a DIFFERENT user's request without needing the
|
||||
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
|
||||
// longer lets them action their own) and not overridable by the enable-same-maker-checker
|
||||
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
||||
// configuration is left in place and unused for this decision rather than removed, since
|
||||
// deleting a still-configurable global setting is a separate decision from this one).
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
}
|
||||
}
|
||||
return commandSource;
|
||||
}
|
||||
|
||||
+1
@@ -505,6 +505,7 @@ public class FineractProperties {
|
||||
private FineractTransactionProcessorItemProperties heavensFamily;
|
||||
private FineractTransactionProcessorItemProperties interestPrincipalPenaltiesFees;
|
||||
private FineractTransactionProcessorItemProperties principalInterestPenaltiesFees;
|
||||
private FineractTransactionProcessorItemProperties feesPenaltiesInterestPrincipal;
|
||||
private FineractTransactionProcessorItemProperties rbiIndia;
|
||||
private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest;
|
||||
private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee;
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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.loanaccount.domain.transactionprocessor.impl;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
|
||||
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
|
||||
|
||||
/**
|
||||
* {@link LoanRepaymentScheduleTransactionProcessor} that pays off components in order of fees, penalties, interest,
|
||||
* then principal.
|
||||
*
|
||||
* Other formulas exist on fineract where you can choose 'Declining-Balance Interest Recalculation' which simply means,
|
||||
* recalculate the interest component based on the how much principal is outstanding at a point in time; but this isnt
|
||||
* trying to model that option only the basic one for now.
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public class FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
|
||||
|
||||
public static final String STRATEGY_CODE = "fees-penalties-interest-principal-order-strategy";
|
||||
|
||||
public static final String STRATEGY_NAME = "Fees, Penalties, Interest, Principal Order";
|
||||
|
||||
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
|
||||
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
|
||||
super(externalIdFactory, loanChargeValidator, loanBalanceService);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCode() {
|
||||
return STRATEGY_CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return STRATEGY_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* For early/'in advance' repayments, pay off in the same way as on-time payments, interest first then principal.
|
||||
*/
|
||||
@Override
|
||||
protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction, final Money paymentInAdvance,
|
||||
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
|
||||
|
||||
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, paymentInAdvance, transactionMappings,
|
||||
charges);
|
||||
}
|
||||
|
||||
/**
|
||||
* For late repayments, pay off in the same way as on-time payments, interest first then principal.
|
||||
*/
|
||||
@Override
|
||||
protected Money handleTransactionThatIsALateRepaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction,
|
||||
final Money transactionAmountUnprocessed, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
|
||||
Set<LoanCharge> charges) {
|
||||
|
||||
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, transactionAmountUnprocessed,
|
||||
transactionMappings, charges);
|
||||
}
|
||||
|
||||
/**
|
||||
* For normal on-time repayments, pays off interest first, then principal.
|
||||
*/
|
||||
@Override
|
||||
protected Money handleTransactionThatIsOnTimePaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
|
||||
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
|
||||
|
||||
final LocalDate transactionDate = loanTransaction.getTransactionDate();
|
||||
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
|
||||
Money transactionAmountRemaining = transactionAmountUnprocessed;
|
||||
Money principalPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
Money interestPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
Money feeChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
Money penaltyChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
|
||||
if (loanTransaction.isChargesWaiver()) {
|
||||
penaltyChargesPortion = currentInstallment.waivePenaltyChargesComponent(transactionDate,
|
||||
loanTransaction.getPenaltyChargesPortion(currency));
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
|
||||
feeChargesPortion = currentInstallment.waiveFeeChargesComponent(transactionDate,
|
||||
loanTransaction.getFeeChargesPortion(currency));
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
|
||||
} else if (loanTransaction.isInterestWaiver()) {
|
||||
interestPortion = currentInstallment.waiveInterestComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
|
||||
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
} else if (loanTransaction.isChargePayment()) {
|
||||
if (loanTransaction.isPenaltyPayment()) {
|
||||
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
} else {
|
||||
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
}
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
} else {
|
||||
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
|
||||
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
|
||||
interestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
|
||||
|
||||
principalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
|
||||
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
}
|
||||
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
|
||||
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
|
||||
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
|
||||
}
|
||||
return transactionAmountRemaining;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Money handleRefundTransactionPaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
|
||||
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings) {
|
||||
|
||||
final LocalDate transactionDate = loanTransaction.getTransactionDate();
|
||||
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
|
||||
Money transactionAmountRemaining = transactionAmountUnprocessed;
|
||||
Money principalPortion = Money.zero(currency);
|
||||
Money interestPortion = Money.zero(currency);
|
||||
Money feeChargesPortion = Money.zero(currency);
|
||||
Money penaltyChargesPortion = Money.zero(currency);
|
||||
|
||||
principalPortion = currentInstallment.unpayPrincipalComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
|
||||
|
||||
if (transactionAmountRemaining.isGreaterThanZero()) {
|
||||
interestPortion = currentInstallment.unpayInterestComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
|
||||
}
|
||||
|
||||
if (transactionAmountRemaining.isGreaterThanZero()) {
|
||||
penaltyChargesPortion = currentInstallment.unpayPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
}
|
||||
|
||||
if (transactionAmountRemaining.isGreaterThanZero()) {
|
||||
feeChargesPortion = currentInstallment.unpayFeeChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
}
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
|
||||
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
|
||||
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
|
||||
}
|
||||
return transactionAmountRemaining;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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.loanaccount.starter;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.condition.PropertiesCondition;
|
||||
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||
|
||||
public class FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition extends PropertiesCondition {
|
||||
|
||||
@Override
|
||||
protected boolean matches(FineractProperties properties) {
|
||||
return properties.getLoan().getTransactionProcessor().getFeesPenaltiesInterestPrincipal().isEnabled();
|
||||
}
|
||||
}
|
||||
+14
@@ -225,6 +225,12 @@ public class LoanProductData implements Serializable {
|
||||
private final LoanProductGuaranteeData productGuaranteeData;
|
||||
private final Boolean accountMovesOutOfNPAOnlyOnArrearsCompletion;
|
||||
private LoanProductConfigurableAttributes allowAttributeOverrides;
|
||||
|
||||
/**
|
||||
* "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book), flat by their form field
|
||||
* name. Set after construction (like {@link #allowAttributeOverrides}) so the edit form can repopulate.
|
||||
*/
|
||||
private Map<String, Object> advanceExtensions;
|
||||
private final boolean syncExpectedWithDisbursementDate;
|
||||
private final boolean isEqualAmortization;
|
||||
private final BigDecimal fixedPrincipalPercentagePerInstallment;
|
||||
@@ -1466,6 +1472,14 @@ public class LoanProductData implements Serializable {
|
||||
this.allowAttributeOverrides = loanProductConfigurableAttributes;
|
||||
}
|
||||
|
||||
public void setAdvanceExtensions(final Map<String, Object> advanceExtensions) {
|
||||
this.advanceExtensions = advanceExtensions;
|
||||
}
|
||||
|
||||
public Map<String, Object> getAdvanceExtensions() {
|
||||
return this.advanceExtensions;
|
||||
}
|
||||
|
||||
public boolean isIsLinkedToFloatingInterestRates() {
|
||||
return this.isLinkedToFloatingInterestRates;
|
||||
}
|
||||
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* "Advances A/C" product configuration that Fineract's loan-product schema has no home for
|
||||
* (security & margin, lien, deposit-linked interest, penal/NPA handling, renewal, monitoring,
|
||||
* cheque book). It is a standalone satellite keyed by {@code loan_product_id} rather than a widening
|
||||
* of {@code m_product_loan} or a new relation on {@link LoanProduct}: the product entity graph is
|
||||
* loaded on the hot loan-origination path, so it is deliberately left untouched.
|
||||
*
|
||||
* Typed columns (not a JSON blob) so reports can filter on them and GL ids get a real FK.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "m_product_loan_advance_ext")
|
||||
public class LoanProductAdvanceExt {
|
||||
|
||||
@Id
|
||||
@Column(name = "loan_product_id")
|
||||
private Long loanProductId;
|
||||
|
||||
/** Shared primary key, mapped read-only; {@link #loanProductId} is the writable side. */
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
|
||||
private LoanProduct loanProduct;
|
||||
|
||||
// ---- classification -------------------------------------------------
|
||||
@Column(name = "product_type_group")
|
||||
private String productTypeGroup;
|
||||
|
||||
@Column(name = "product_type_sub_group")
|
||||
private String productTypeSubGroup;
|
||||
|
||||
@Column(name = "gl_account_id")
|
||||
private Long glAccountId;
|
||||
|
||||
@Column(name = "loan_product_category")
|
||||
private String loanProductCategory;
|
||||
|
||||
@Column(name = "priority_sector")
|
||||
private String prioritySector;
|
||||
|
||||
@Column(name = "cibil_account_type")
|
||||
private String cibilAccountType;
|
||||
|
||||
@Column(name = "kfs_enabled")
|
||||
private Boolean kfsEnabled;
|
||||
|
||||
@Column(name = "apr_enabled")
|
||||
private Boolean aprEnabled;
|
||||
|
||||
// ---- facility -------------------------------------------------------
|
||||
@Column(name = "facility_type")
|
||||
private String facilityType;
|
||||
|
||||
// ---- security & margin ----------------------------------------------
|
||||
@Column(name = "security_type")
|
||||
private String securityType;
|
||||
|
||||
@Column(name = "charge_type")
|
||||
private String chargeType;
|
||||
|
||||
@Column(name = "eligible_amount_basis")
|
||||
private String eligibleAmountBasis;
|
||||
|
||||
@Column(name = "margin_pct")
|
||||
private BigDecimal marginPct;
|
||||
|
||||
@Column(name = "ltv_pct")
|
||||
private BigDecimal ltvPct;
|
||||
|
||||
@Column(name = "dp_method")
|
||||
private String dpMethod;
|
||||
|
||||
// ---- lien -----------------------------------------------------------
|
||||
@Column(name = "lien_required")
|
||||
private Boolean lienRequired;
|
||||
|
||||
@Column(name = "lien_marking_stage")
|
||||
private String lienMarkingStage;
|
||||
|
||||
@Column(name = "lien_certificate_required")
|
||||
private Boolean lienCertificateRequired;
|
||||
|
||||
@Column(name = "lien_certificate_prefix")
|
||||
private String lienCertificatePrefix;
|
||||
|
||||
@Column(name = "print_certificate_on_disbursement")
|
||||
private Boolean printCertificateOnDisbursement;
|
||||
|
||||
// ---- interest (extras; rate bounds live on the core product) --------
|
||||
@Column(name = "interest_basis")
|
||||
private String interestBasis;
|
||||
|
||||
@Column(name = "spread_pct")
|
||||
private BigDecimal spreadPct;
|
||||
|
||||
@Column(name = "interest_charged_on")
|
||||
private String interestChargedOn;
|
||||
|
||||
@Column(name = "interest_posting_frequency")
|
||||
private String interestPostingFrequency;
|
||||
|
||||
// ---- penal & NPA (extras; npaDays/grace/tolerance live on core) ------
|
||||
@Column(name = "penal_applicable")
|
||||
private Boolean penalApplicable;
|
||||
|
||||
@Column(name = "penal_rate_pct")
|
||||
private BigDecimal penalRatePct;
|
||||
|
||||
@Column(name = "penal_applied_on")
|
||||
private String penalAppliedOn;
|
||||
|
||||
@Column(name = "penal_grace_days")
|
||||
private Integer penalGraceDays;
|
||||
|
||||
@Column(name = "npa_trigger")
|
||||
private String npaTrigger;
|
||||
|
||||
@Column(name = "interest_after_npa")
|
||||
private String interestAfterNpa;
|
||||
|
||||
@Column(name = "undebited_interest_gl_id")
|
||||
private Long undebitedInterestGlId;
|
||||
|
||||
// ---- renewal & closure ----------------------------------------------
|
||||
@Column(name = "renewal_allowed")
|
||||
private Boolean renewalAllowed;
|
||||
|
||||
@Column(name = "renewal_period_months")
|
||||
private Integer renewalPeriodMonths;
|
||||
|
||||
@Column(name = "renewal_grace_days")
|
||||
private Integer renewalGraceDays;
|
||||
|
||||
@Column(name = "preclosure_allowed")
|
||||
private String preclosureAllowed;
|
||||
|
||||
@Column(name = "lock_in_months")
|
||||
private Integer lockInMonths;
|
||||
|
||||
@Column(name = "interest_on_preclosure")
|
||||
private String interestOnPreclosure;
|
||||
|
||||
// ---- monitoring -----------------------------------------------------
|
||||
@Column(name = "revaluation_freq_months")
|
||||
private Integer revaluationFreqMonths;
|
||||
|
||||
@Column(name = "insurance_required")
|
||||
private Boolean insuranceRequired;
|
||||
|
||||
@Column(name = "stock_statement_freq_months")
|
||||
private Integer stockStatementFreqMonths;
|
||||
|
||||
// ---- cheque book ----------------------------------------------------
|
||||
@Column(name = "cheque_book_enabled")
|
||||
private Boolean chequeBookEnabled;
|
||||
|
||||
@Column(name = "cheque_type")
|
||||
private String chequeType;
|
||||
|
||||
@Column(name = "default_cheque_book_size")
|
||||
private Integer defaultChequeBookSize;
|
||||
|
||||
@Column(name = "pps_threshold")
|
||||
private BigDecimal ppsThreshold;
|
||||
|
||||
@Column(name = "pps_mandatory_above")
|
||||
private BigDecimal ppsMandatoryAbove;
|
||||
|
||||
// ---- share (co-op) --------------------------------------------------
|
||||
@Column(name = "share_capital_gl_id")
|
||||
private Long shareCapitalGlId;
|
||||
|
||||
// ---- security & lien extras -----------------------------------------
|
||||
/** Pledgeable deposit product names, joined with "|". */
|
||||
@Column(name = "eligible_deposit_products")
|
||||
private String eligibleDepositProducts;
|
||||
|
||||
@Column(name = "lien_tenure_not_exceed_maturity")
|
||||
private Boolean lienTenureNotExceedMaturity;
|
||||
|
||||
@Column(name = "lien_auto_release_on_closure")
|
||||
private Boolean lienAutoReleaseOnClosure;
|
||||
|
||||
@Column(name = "lien_allow_multiple_deposits")
|
||||
private Boolean lienAllowMultipleDeposits;
|
||||
|
||||
@Column(name = "lien_adjust_from_maturity")
|
||||
private Boolean lienAdjustFromMaturity;
|
||||
|
||||
@Column(name = "lien_allow_third_party")
|
||||
private Boolean lienAllowThirdParty;
|
||||
|
||||
@Column(name = "lien_allow_renewal_under_lien")
|
||||
private Boolean lienAllowRenewalUnderLien;
|
||||
|
||||
// ---- limit & tenure extras ------------------------------------------
|
||||
@Column(name = "repayment_type")
|
||||
private String repaymentType;
|
||||
|
||||
@Column(name = "appropriation_order")
|
||||
private String appropriationOrder;
|
||||
|
||||
@Column(name = "debit_balance_allow")
|
||||
private Boolean debitBalanceAllow;
|
||||
|
||||
@Column(name = "allow_debit_after_due_date")
|
||||
private Boolean allowDebitAfterDueDate;
|
||||
|
||||
@Column(name = "branch_level_account_type")
|
||||
private Boolean branchLevelAccountType;
|
||||
|
||||
// ---- interest extras ------------------------------------------------
|
||||
@Column(name = "interest_calc_method")
|
||||
private String interestCalcMethod;
|
||||
|
||||
@Column(name = "interest_on_credit_balance")
|
||||
private Boolean interestOnCreditBalance;
|
||||
|
||||
@Column(name = "interest_posting_day")
|
||||
private String interestPostingDay;
|
||||
|
||||
// ---- NPA upgrade rules ----------------------------------------------
|
||||
@Column(name = "npa_upgrade_only_all_arrears")
|
||||
private Boolean npaUpgradeOnlyAllArrears;
|
||||
|
||||
@Column(name = "npa_keep_standard_if_deposit_covers")
|
||||
private Boolean npaKeepStandardIfDepositCovers;
|
||||
|
||||
// ---- cheque book full config ----------------------------------------
|
||||
/** Allowed book sizes (leaves), joined with "|". */
|
||||
@Column(name = "cheque_book_sizes")
|
||||
private String chequeBookSizes;
|
||||
|
||||
@Column(name = "cheque_max_books_outstanding")
|
||||
private Integer chequeMaxBooksOutstanding;
|
||||
|
||||
@Column(name = "cheque_reissue_when_leaves")
|
||||
private Integer chequeReissueWhenLeaves;
|
||||
|
||||
@Column(name = "cheque_first_book_free")
|
||||
private Boolean chequeFirstBookFree;
|
||||
|
||||
@Column(name = "cheque_free_books_per_year")
|
||||
private Integer chequeFreeBooksPerYear;
|
||||
|
||||
@Column(name = "cheque_name_printed")
|
||||
private String chequeNamePrinted;
|
||||
|
||||
@Column(name = "cheque_print_description")
|
||||
private String chequePrintDescription;
|
||||
|
||||
@Column(name = "cheque_stop_payment_allowed")
|
||||
private Boolean chequeStopPaymentAllowed;
|
||||
|
||||
@Column(name = "cheque_maintain_stock_register")
|
||||
private Boolean chequeMaintainStockRegister;
|
||||
|
||||
@Column(name = "cheque_allow_withdrawal_slip")
|
||||
private Boolean chequeAllowWithdrawalSlip;
|
||||
|
||||
@Column(name = "cheque_block_npa")
|
||||
private Boolean chequeBlockNpa;
|
||||
|
||||
@Column(name = "cheque_block_limit_expired")
|
||||
private Boolean chequeBlockLimitExpired;
|
||||
|
||||
@Column(name = "cheque_block_over_dp")
|
||||
private Boolean chequeBlockOverDp;
|
||||
|
||||
@Column(name = "cheque_block_frozen")
|
||||
private Boolean chequeBlockFrozen;
|
||||
|
||||
@Column(name = "cheque_block_lien_released")
|
||||
private Boolean chequeBlockLienReleased;
|
||||
|
||||
// ---- Loans A/C (EMI) wizard fields -------------------------------------
|
||||
@Column(name = "loan_security_mandatory")
|
||||
private String securityMandatory;
|
||||
|
||||
@Column(name = "loan_guarantors_required")
|
||||
private Integer guarantorsRequired;
|
||||
|
||||
@Column(name = "loan_co_applicant")
|
||||
private String coApplicant;
|
||||
|
||||
@Column(name = "loan_third_party_security")
|
||||
private String thirdPartySecurity;
|
||||
|
||||
@Column(name = "loan_legal_opinion_required")
|
||||
private Boolean legalOpinionRequired;
|
||||
|
||||
@Column(name = "loan_property_insurance_required")
|
||||
private Boolean propertyInsuranceRequired;
|
||||
|
||||
@Column(name = "loan_register_charge")
|
||||
private Boolean registerCharge;
|
||||
|
||||
@Column(name = "loan_release_security_on_closure")
|
||||
private Boolean releaseSecurityOnClosure;
|
||||
|
||||
@Column(name = "loan_allow_multiple_securities")
|
||||
private Boolean allowMultipleSecurities;
|
||||
|
||||
@Column(name = "loan_allow_third_party_deposit")
|
||||
private Boolean allowThirdPartyDeposit;
|
||||
|
||||
@Column(name = "loan_min_age")
|
||||
private Integer minAge;
|
||||
|
||||
@Column(name = "loan_max_age_at_maturity")
|
||||
private Integer maxAgeAtMaturity;
|
||||
|
||||
@Column(name = "loan_min_cibil")
|
||||
private Integer minCibil;
|
||||
|
||||
@Column(name = "loan_min_monthly_income")
|
||||
private BigDecimal minMonthlyIncome;
|
||||
|
||||
@Column(name = "loan_max_foir")
|
||||
private BigDecimal maxFoir;
|
||||
|
||||
@Column(name = "loan_max_loans_per_customer")
|
||||
private Integer maxLoansPerCustomer;
|
||||
|
||||
@Column(name = "loan_disbursement_type")
|
||||
private String disbursementType;
|
||||
|
||||
@Column(name = "loan_disbursement_mode")
|
||||
private String disbursementMode;
|
||||
|
||||
@Column(name = "loan_undisbursed_interest")
|
||||
private String undisbursedInterest;
|
||||
|
||||
@Column(name = "loan_pre_emi_interest")
|
||||
private Boolean preEmiInterest;
|
||||
|
||||
@Column(name = "loan_emi_due_day")
|
||||
private String emiDueDay;
|
||||
|
||||
@Column(name = "loan_holiday_rule")
|
||||
private String holidayRule;
|
||||
|
||||
@Column(name = "loan_moratorium_months")
|
||||
private Integer moratoriumMonths;
|
||||
|
||||
@Column(name = "loan_moratorium_interest")
|
||||
private String moratoriumInterest;
|
||||
|
||||
@Column(name = "loan_variable_installments")
|
||||
private Boolean variableInstallments;
|
||||
|
||||
@Column(name = "loan_repayment_mode")
|
||||
private String repaymentMode;
|
||||
|
||||
@Column(name = "loan_linked_account_type")
|
||||
private String linkedAccountType;
|
||||
|
||||
@Column(name = "loan_pdc_collection")
|
||||
private String pdcCollection;
|
||||
|
||||
@Column(name = "loan_mandate_required")
|
||||
private Boolean mandateRequired;
|
||||
|
||||
@Column(name = "loan_advance_emi")
|
||||
private String advanceEmi;
|
||||
|
||||
@Column(name = "loan_rate_type")
|
||||
private String rateType;
|
||||
|
||||
@Column(name = "loan_register_particular")
|
||||
private String registerParticular;
|
||||
|
||||
@Column(name = "loan_apply_cibil_adjustment")
|
||||
private Boolean applyCibilAdjustment;
|
||||
|
||||
@Column(name = "loan_rate_resolved_at")
|
||||
private String rateResolvedAt;
|
||||
|
||||
@Column(name = "loan_calc_period")
|
||||
private String calcPeriod;
|
||||
|
||||
@Column(name = "loan_interest_starts_from")
|
||||
private String interestStartsFrom;
|
||||
|
||||
@Column(name = "loan_compounding")
|
||||
private String compounding;
|
||||
|
||||
@Column(name = "loan_benchmark")
|
||||
private String benchmark;
|
||||
|
||||
@Column(name = "loan_reset_frequency")
|
||||
private String resetFrequency;
|
||||
|
||||
@Column(name = "loan_reset_change")
|
||||
private String resetChange;
|
||||
|
||||
@Column(name = "loan_part_prepayment_allowed")
|
||||
private Boolean partPrepaymentAllowed;
|
||||
|
||||
@Column(name = "loan_min_part_payment")
|
||||
private BigDecimal minPartPayment;
|
||||
|
||||
@Column(name = "loan_allowed_on")
|
||||
private String allowedOn;
|
||||
|
||||
@Column(name = "loan_part_payments_per_year")
|
||||
private String partPaymentsPerYear;
|
||||
|
||||
@Column(name = "loan_part_prepayment_mode")
|
||||
private String partPrepaymentMode;
|
||||
|
||||
@Column(name = "loan_foreclosure_floating_individual")
|
||||
private BigDecimal foreclosureFloatingIndividual;
|
||||
|
||||
@Column(name = "loan_foreclosure_fixed_individual")
|
||||
private BigDecimal foreclosureFixedIndividual;
|
||||
|
||||
@Column(name = "loan_foreclosure_non_individual")
|
||||
private BigDecimal foreclosureNonIndividual;
|
||||
|
||||
@Column(name = "loan_foreclosure_takeover")
|
||||
private BigDecimal foreclosureTakeover;
|
||||
|
||||
@Column(name = "loan_foreclosure_quote_valid_days")
|
||||
private Integer foreclosureQuoteValidDays;
|
||||
|
||||
@Column(name = "loan_auto_close_on_zero_balance")
|
||||
private Boolean autoCloseOnZeroBalance;
|
||||
|
||||
@Column(name = "loan_noc_letter")
|
||||
private String nocLetter;
|
||||
|
||||
@Column(name = "loan_restructuring")
|
||||
private String restructuring;
|
||||
|
||||
@Column(name = "loan_release_collateral_on_closure")
|
||||
private Boolean releaseCollateralOnClosure;
|
||||
|
||||
@Column(name = "loan_top_up_allowed")
|
||||
private Boolean topUpAllowed;
|
||||
|
||||
@Column(name = "loan_balance_transfer_out_allowed")
|
||||
private Boolean balanceTransferOutAllowed;
|
||||
|
||||
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
|
||||
this.loanProduct = loanProduct;
|
||||
this.loanProductId = loanProduct.getId();
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanProductAdvanceExtRepository extends JpaRepository<LoanProductAdvanceExt, Long> {
|
||||
|
||||
Optional<LoanProductAdvanceExt> findByLoanProductId(Long loanProductId);
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.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.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* One CIBIL/credit-score band of a rate register version — e.g. {601..650} tier "Fair" adds +1.00%.
|
||||
* Applied on top of the chart cell at loan sanction. Typically 5 rows per version; ordered on reads
|
||||
* by {@code displayOrder}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_cibil_slab")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterCibilSlab {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "effective_id", nullable = false)
|
||||
private Long effectiveId;
|
||||
|
||||
@Column(name = "score_from", nullable = false)
|
||||
private Integer scoreFrom;
|
||||
|
||||
@Column(name = "score_to", nullable = false)
|
||||
private Integer scoreTo;
|
||||
|
||||
@Column(name = "tier", length = 30, nullable = false)
|
||||
private String tier;
|
||||
|
||||
@Column(name = "slab_type", length = 20, nullable = false)
|
||||
private String slabType;
|
||||
|
||||
@Column(name = "adjustment_pct", nullable = false)
|
||||
private BigDecimal adjustmentPct;
|
||||
|
||||
@Column(name = "display_order", nullable = false)
|
||||
private Integer displayOrder;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
public static LoanRateRegisterCibilSlab create(final Long effectiveId, final Integer scoreFrom, final Integer scoreTo, final String tier,
|
||||
final String slabType, final BigDecimal adjustmentPct, final Integer displayOrder) {
|
||||
final LoanRateRegisterCibilSlab s = new LoanRateRegisterCibilSlab();
|
||||
s.effectiveId = effectiveId;
|
||||
s.scoreFrom = scoreFrom;
|
||||
s.scoreTo = scoreTo;
|
||||
s.tier = tier;
|
||||
s.slabType = slabType != null ? slabType : "Flat";
|
||||
s.adjustmentPct = adjustmentPct;
|
||||
s.displayOrder = displayOrder;
|
||||
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return s;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterCibilSlabRepository extends JpaRepository<LoanRateRegisterCibilSlab, Long> {
|
||||
|
||||
/** CIBIL bands for a version, ordered as the UI shows them (ascending score bands). */
|
||||
List<LoanRateRegisterCibilSlab> findByEffectiveIdOrderByDisplayOrderAsc(Long effectiveId);
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.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.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* One effective (versioned) copy of the central Loan Interest Rate Register. Mirrors the
|
||||
* {@code FdBaseInterestRateEffective} versioning model: each approved save creates a new row with
|
||||
* {@code isActive=true} and deactivates the previous active row ({@code isActive=false,
|
||||
* effectiveToDate=today}). Reads for "current active" filter on {@code isActive=true}.
|
||||
*
|
||||
* The 34-product × 10-slab rate matrix for this version lives in {@link LoanRateRegisterSlabHistory};
|
||||
* the CIBIL/credit-score bands live in {@link LoanRateRegisterCibilSlab}, both keyed by this row's
|
||||
* id and cascade-deleted with it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_effective")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterEffective {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", length = 200, nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "effective_from_date", nullable = false)
|
||||
private LocalDate effectiveFromDate;
|
||||
|
||||
@Column(name = "effective_to_date")
|
||||
private LocalDate effectiveToDate;
|
||||
|
||||
@Column(name = "currency_code", length = 3, nullable = false)
|
||||
private String currencyCode;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "fallback_no_cibil_slab", nullable = false)
|
||||
private Integer fallbackNoCibilSlab;
|
||||
|
||||
@Column(name = "fallback_below_min", length = 20, nullable = false)
|
||||
private String fallbackBelowMin;
|
||||
|
||||
@Column(name = "note", length = 500)
|
||||
private String note;
|
||||
|
||||
@Column(name = "created_by_id")
|
||||
private Long createdById;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
public static LoanRateRegisterEffective create(final String name, final LocalDate effectiveFromDate, final String currencyCode,
|
||||
final Integer fallbackNoCibilSlab, final String fallbackBelowMin, final String note, final Long createdById) {
|
||||
final LoanRateRegisterEffective e = new LoanRateRegisterEffective();
|
||||
e.name = name;
|
||||
e.effectiveFromDate = effectiveFromDate;
|
||||
e.currencyCode = currencyCode != null ? currencyCode : "INR";
|
||||
e.isActive = true;
|
||||
e.fallbackNoCibilSlab = fallbackNoCibilSlab != null ? fallbackNoCibilSlab : 2;
|
||||
e.fallbackBelowMin = fallbackBelowMin != null ? fallbackBelowMin : "block";
|
||||
e.note = note;
|
||||
e.createdById = createdById;
|
||||
e.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Flip this version to inactive and close its effective window. Called when a newer version is approved. */
|
||||
public void deactivate(final LocalDate toDate) {
|
||||
this.isActive = false;
|
||||
this.effectiveToDate = toDate;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterEffectiveRepository extends JpaRepository<LoanRateRegisterEffective, Long> {
|
||||
|
||||
/** All active versions, newest-effective first. At most one row in a healthy system. */
|
||||
List<LoanRateRegisterEffective> findByIsActiveTrueOrderByEffectiveFromDateDesc();
|
||||
|
||||
/** The current active version, if any. */
|
||||
Optional<LoanRateRegisterEffective> findFirstByIsActiveTrueOrderByEffectiveFromDateDesc();
|
||||
|
||||
/** Version history list — all versions, newest first. */
|
||||
List<LoanRateRegisterEffective> findAllByOrderByEffectiveFromDateDesc();
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.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.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A "particular" (loan type) row of the Loan Interest Rate Register chart. The GL account is a
|
||||
* property of the particular, so it lives here rather than on each rate-register version. Kept as a
|
||||
* plain {@code gl_account_id} column (no relation) to avoid pulling the accounting module's entity
|
||||
* graph into the loan module.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_product")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterProduct {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "sr_no", nullable = false)
|
||||
private Integer srNo;
|
||||
|
||||
@Column(name = "name", length = 200, nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "gl_account_id")
|
||||
private Long glAccountId;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterProductRepository extends JpaRepository<LoanRateRegisterProduct, Long> {
|
||||
|
||||
/** Active particulars in chart order. */
|
||||
List<LoanRateRegisterProduct> findByIsActiveTrueOrderBySrNoAsc();
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.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.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* A single cell of a rate register version — one row per {product × amount-slab}. Product and slab
|
||||
* metadata are denormalised so one query can rebuild the full matrix without a join. The cell holds
|
||||
* either a plain numeric rate, a formula string ({@code "1% + FD"}) resolved at sanction, or
|
||||
* N.A. (both value and formula null, {@code isNa=true}) to mean "slab not offered".
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_slab_history")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterSlabHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "effective_id", nullable = false)
|
||||
private Long effectiveId;
|
||||
|
||||
// ---- product dimension ----------------------------------------------
|
||||
/** Stable link to the master particular (and its GL). Null only for rows written before the master existed. */
|
||||
@Column(name = "product_id")
|
||||
private Long productId;
|
||||
|
||||
@Column(name = "product_sr_no", nullable = false)
|
||||
private Integer productSrNo;
|
||||
|
||||
@Column(name = "product_name", length = 200, nullable = false)
|
||||
private String productName;
|
||||
|
||||
// ---- amount-slab dimension ------------------------------------------
|
||||
@Column(name = "slab_order", nullable = false)
|
||||
private Integer slabOrder;
|
||||
|
||||
@Column(name = "slab_label", length = 60, nullable = false)
|
||||
private String slabLabel;
|
||||
|
||||
@Column(name = "amount_upto")
|
||||
private BigDecimal amountUpto;
|
||||
|
||||
// ---- cell value (exactly one of value / formula is populated; both null + isNa=true means N.A.) ----
|
||||
@Column(name = "rate_value")
|
||||
private BigDecimal rateValue;
|
||||
|
||||
@Column(name = "rate_formula", length = 50)
|
||||
private String rateFormula;
|
||||
|
||||
@Column(name = "is_formula", nullable = false)
|
||||
private Boolean isFormula;
|
||||
|
||||
@Column(name = "is_na", nullable = false)
|
||||
private Boolean isNa;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Long productId, final Integer productSrNo,
|
||||
final String productName, final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto,
|
||||
final BigDecimal rateValue, final String rateFormula, final boolean isFormula, final boolean isNa) {
|
||||
final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory();
|
||||
s.effectiveId = effectiveId;
|
||||
s.productId = productId;
|
||||
s.productSrNo = productSrNo;
|
||||
s.productName = productName;
|
||||
s.slabOrder = slabOrder;
|
||||
s.slabLabel = slabLabel;
|
||||
s.amountUpto = amountUpto;
|
||||
s.rateValue = rateValue;
|
||||
s.rateFormula = rateFormula;
|
||||
s.isFormula = isFormula;
|
||||
s.isNa = isNa;
|
||||
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return s;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterSlabHistoryRepository extends JpaRepository<LoanRateRegisterSlabHistory, Long> {
|
||||
|
||||
/** All cells of a version, ordered by product then by amount-slab. Rebuilds the full matrix in order. */
|
||||
List<LoanRateRegisterSlabHistory> findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(Long effectiveId);
|
||||
}
|
||||
+15
-10
@@ -79,6 +79,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@@ -102,6 +103,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||
private final AccountingProcessorHelper accountingProcessorHelper;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -412,6 +414,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
toSavingsAccount, withdrawal, deposit);
|
||||
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
|
||||
transferTransactionId = accountTransferDetails.getId();
|
||||
// FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted.
|
||||
this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit,
|
||||
AccountTransferType.fromInt(accountTransferDTO.getTransferType()));
|
||||
|
||||
} else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
|
||||
|
||||
@@ -703,8 +708,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
currentAccountRepository.save(account);
|
||||
if (account.getProductId() != null && txn.getId() != null) {
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
account.getId(), currentTxnId, txnDate, amount);
|
||||
}
|
||||
return txn;
|
||||
@@ -715,7 +720,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
@@ -735,7 +740,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -744,7 +749,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Deposit amount into a current account and save the transaction.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
@@ -757,7 +762,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -803,13 +808,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
if (isWithdrawal) {
|
||||
// DR: current account GL CR: counterpart (savings/current) GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||
currentTxnId, txnDate, amount);
|
||||
} else {
|
||||
// DR: counterpart (savings/current) GL CR: current account GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
currentTxnId, txnDate, amount);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -97,12 +98,14 @@ public class AccountConfiguration {
|
||||
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
||||
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
||||
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard,
|
||||
SavingsInterestTdsService savingsInterestTdsService) {
|
||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard,
|
||||
savingsInterestTdsService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+20
-5
@@ -122,11 +122,26 @@ public class BranchDayRegisterApiResource {
|
||||
result.put("officeId", officeId);
|
||||
result.put("canOperate", canOperateDay(currentUser));
|
||||
result.put("days", mapDays(rows));
|
||||
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
|
||||
result.put("today", DateUtils.getBusinessLocalDate().toString());
|
||||
result.put("openDay", findOpenDay(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/today ── the bank's "today" ─────────────────────────
|
||||
|
||||
/**
|
||||
* The business date while enable-business-date is on, the tenant's own date while it is off.
|
||||
* Every screen that defaults a date to "today" reads this, rather than the browser clock or the
|
||||
* business-date API (which returns the stored date even when the feature is off). Open to any
|
||||
* signed-in user — cashiers and clerks cannot read the configuration that says which applies.
|
||||
*/
|
||||
@GET
|
||||
@Path("today")
|
||||
public String today() {
|
||||
this.context.authenticatedUser();
|
||||
return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString()));
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -139,7 +154,7 @@ public class BranchDayRegisterApiResource {
|
||||
public String missed(@QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
||||
@@ -253,7 +268,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
DateUtils.getBusinessLocalDate());
|
||||
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
@@ -447,7 +462,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
DateUtils.getBusinessLocalDate());
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
|
||||
@@ -801,7 +816,7 @@ public class BranchDayRegisterApiResource {
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank())
|
||||
? LocalDate.parse(businessDateParam)
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
|
||||
+10
-9
@@ -727,7 +727,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long savingsAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -761,7 +761,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -790,7 +790,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -844,7 +844,7 @@ public class CashierDepositRequestApiResource {
|
||||
return;
|
||||
}
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -867,7 +867,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long currentAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -976,7 +976,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Object paymentTypeId, final String txnDateStr, final String note,
|
||||
final boolean isDeposit) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
// Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra)
|
||||
@@ -1073,8 +1073,9 @@ public class CashierDepositRequestApiResource {
|
||||
value, count, BigDecimal.valueOf((long) value * count),
|
||||
// Dated on the request's own date, which is what the branch day's book figure reads.
|
||||
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
|
||||
// drawer on another and leave both days' cash wrong.
|
||||
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
|
||||
// drawer on another and leave both days' cash wrong. No date falls back to the
|
||||
// business date for the same reason.
|
||||
Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()),
|
||||
cashierId, cashierUsername, paymentTypeName
|
||||
);
|
||||
}
|
||||
@@ -1179,7 +1180,7 @@ public class CashierDepositRequestApiResource {
|
||||
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -83,7 +83,7 @@ public class CashReportApiResource {
|
||||
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
|
||||
@QueryParam("status") final String status) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
@@ -470,7 +470,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("closing")
|
||||
public String closing(@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
|
||||
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
||||
@@ -938,7 +938,7 @@ public class CashReportApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String today = DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// Find the earliest transfer date before today for which no settlement exists on or after that date
|
||||
final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled "
|
||||
@@ -1001,7 +1001,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-position")
|
||||
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// CASHIER role: always scope to their own position only
|
||||
final Long scopedId = cashierRoleHelper.getCashierIdForFiltering();
|
||||
@@ -1032,7 +1032,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("cashier-position/me")
|
||||
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
Long appUserId;
|
||||
try {
|
||||
appUserId = context.authenticatedUser().getId();
|
||||
@@ -1070,7 +1070,7 @@ public class CashReportApiResource {
|
||||
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
|
||||
@QueryParam("asOfDate") final String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
: DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
if (tellerId == null) {
|
||||
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
|
||||
@@ -1092,7 +1092,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-morning")
|
||||
public String cashierMorning(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
if (cashierId == null) {
|
||||
Map<String, Object> empty = new LinkedHashMap<>();
|
||||
empty.put("cashierId", null);
|
||||
@@ -1304,7 +1304,7 @@ public class CashReportApiResource {
|
||||
@QueryParam("offset") Integer offset,
|
||||
@QueryParam("limit") Integer limit) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final int resolvedLimit = (limit != null && limit > 0) ? limit : 20;
|
||||
final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0;
|
||||
|
||||
|
||||
+3
-3
@@ -89,7 +89,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
|
||||
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate();
|
||||
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
|
||||
? json.get("fromParty").getAsString() : null;
|
||||
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
|
||||
@@ -226,7 +226,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
glDispatchService.allocateCashToCashier(glTellerId,
|
||||
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -247,7 +247,7 @@ public class CashSettlementApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required",
|
||||
"cashierId query param is required.");
|
||||
}
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
|
||||
Integer.class, cashierId, settlementDate);
|
||||
|
||||
+3
-3
@@ -146,7 +146,7 @@ public class CashTransferApiResource {
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total,
|
||||
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
note != null ? note : "");
|
||||
@@ -206,7 +206,7 @@ public class CashTransferApiResource {
|
||||
}
|
||||
glDispatchService.settleCashFromCashier(glTellerId,
|
||||
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
|
||||
java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -291,7 +291,7 @@ public class CashTransferApiResource {
|
||||
"Vault/teller " + fromTellerId + " was not found.");
|
||||
}
|
||||
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.query("""
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource {
|
||||
final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank())
|
||||
? LocalDate.parse(txnDateStr)
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) {
|
||||
return "{\"result\":\"skipped\"}";
|
||||
|
||||
+5
-5
@@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Long resolvedClientId = toLong(dep.get("client_id"));
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
|
||||
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
final String receiptNo = nextReceiptNo();
|
||||
// What the cashier actually typed at save time, when they typed anything -- that is what
|
||||
// the Apply Share screen's Description column exists to show. Falls back to a plain
|
||||
@@ -927,7 +927,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
final String voucherNo = (String) req.get("voucher_no");
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1006,7 +1006,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
|
||||
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
// What the cashier actually typed at save time, when they typed anything -- same preference
|
||||
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
|
||||
@@ -1093,7 +1093,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1338,7 +1338,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
private LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
|
||||
+17
-4
@@ -451,8 +451,18 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// not-applicable rather than failed for a GL request — a share account with no linked
|
||||
// savings is a perfectly valid GL-settlement case.
|
||||
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
|
||||
checks.add(glSettlementMode
|
||||
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
|
||||
// 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who
|
||||
// names the product's own Share Sundry GL as the destination is saying "stop here", so the
|
||||
// request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry
|
||||
// and nothing further. Like a GL settlement it never reaches a savings account, so the
|
||||
// savings-side checks below do not apply to it either — running them reports a missing fund
|
||||
// source mapping that no posting in this mode is ever going to read.
|
||||
final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode"));
|
||||
final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement;
|
||||
checks.add(noSavingsLeg
|
||||
? checkResult("Linked savings account is Active", true, sundryOnlySettlement
|
||||
? "Not applicable — the redemption rests in Share Sundry"
|
||||
: "Not applicable — settlement is a GL book-transfer")
|
||||
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||
savingsAccountId == null ? "No linked savings account configured"
|
||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||
@@ -541,6 +551,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
final boolean settlementGlPresent = glAccountIdObj != null;
|
||||
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
|
||||
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
|
||||
} else if (sundryOnlySettlement) {
|
||||
checks.add(checkResult("Settlement destination recorded", true,
|
||||
"Share Sundry — the redemption stops there, so no fund source mapping is read"));
|
||||
} else {
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
@@ -992,8 +1005,8 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
|
||||
final BigDecimal unitPrice = jdbcTemplate
|
||||
.queryForList("""
|
||||
SELECT unit_price FROM m_share_account_transaction
|
||||
WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
|
||||
SELECT unit_price FROM m_share_account_transactions
|
||||
WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", BigDecimal.class, shareAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
|
||||
+10
@@ -27,6 +27,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor;
|
||||
@@ -98,6 +99,15 @@ public class LoanAccountAutoStarter {
|
||||
loanBalanceService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Conditional(FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.class)
|
||||
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor feesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(
|
||||
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
|
||||
final LoanBalanceService loanBalanceService) {
|
||||
return new FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator,
|
||||
loanBalanceService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Conditional(RBIIndiaLoanRepaymentScheduleTransactionProcessorCondition.class)
|
||||
public RBILoanRepaymentScheduleTransactionProcessor rbiLoanRepaymentScheduleTransactionProcessor(
|
||||
|
||||
+57
-2
@@ -37,10 +37,15 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -58,6 +63,7 @@ import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiFacingEnum;
|
||||
@@ -107,6 +113,7 @@ import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundEx
|
||||
import org.apache.fineract.portfolio.loanproduct.productmix.data.ProductMixData;
|
||||
import org.apache.fineract.portfolio.loanproduct.productmix.service.ProductMixReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||
@@ -142,7 +149,8 @@ public class LoanProductsApiResource {
|
||||
LoanProductConstants.CAPITALIZED_INCOME_CALCULATION_TYPE_PARAM_NAME,
|
||||
LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME,
|
||||
LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME,
|
||||
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME));
|
||||
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME,
|
||||
"advanceExtensions"));
|
||||
|
||||
private static final Set<String> PRODUCT_MIX_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("productId", "productName", "restrictedProducts", "allowedProducts", "productOptions"));
|
||||
@@ -153,6 +161,7 @@ public class LoanProductsApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final LoanProductReadPlatformService loanProductReadPlatformService;
|
||||
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
|
||||
private final ChargeReadPlatformService chargeReadPlatformService;
|
||||
private final CurrencyReadPlatformService currencyReadPlatformService;
|
||||
private final FundReadPlatformService fundReadPlatformService;
|
||||
@@ -219,11 +228,54 @@ public class LoanProductsApiResource {
|
||||
return this.productMixDataApiJsonSerializer.serialize(settings, productMixes, PRODUCT_MIX_DATA_PARAMETERS);
|
||||
}
|
||||
|
||||
final Collection<LoanProductData> products = this.loanProductReadPlatformService.retrieveAllLoanProducts();
|
||||
final Collection<LoanProductData> products = enrichAndFilterProducts(
|
||||
this.loanProductReadPlatformService.retrieveAllLoanProducts(), uriInfo);
|
||||
|
||||
return this.toApiJsonSerializer.serialize(settings, products, LOAN_PRODUCT_DATA_PARAMETERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds each product's category and type ("Advances A/C" or "Loans A/C" and its sub type) to the list, and applies
|
||||
* the optional filters {@code activeOnly}, {@code productTypeGroup} and {@code productTypeSubGroup}. With none of
|
||||
* them given the list holds the same products as before, each now carrying its classification.
|
||||
*/
|
||||
private Collection<LoanProductData> enrichAndFilterProducts(final Collection<LoanProductData> products, final UriInfo uriInfo) {
|
||||
final MultivaluedMap<String, String> query = uriInfo.getQueryParameters();
|
||||
final boolean activeOnly = Boolean.parseBoolean(query.getFirst("activeOnly"));
|
||||
final String group = trimToNull(query.getFirst("productTypeGroup"));
|
||||
final String subGroup = trimToNull(query.getFirst("productTypeSubGroup"));
|
||||
final Map<Long, Map<String, Object>> extensions = this.loanProductAdvanceExtService.retrieveAllAsMap();
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<LoanProductData> result = new ArrayList<>();
|
||||
for (final LoanProductData product : products) {
|
||||
final Map<String, Object> ext = extensions.getOrDefault(product.getId(), Collections.emptyMap());
|
||||
if (activeOnly && !isActiveOn(product, today)) {
|
||||
continue;
|
||||
}
|
||||
if (group != null && !group.equalsIgnoreCase(String.valueOf(ext.get("productTypeGroup")))) {
|
||||
continue;
|
||||
}
|
||||
if (subGroup != null && !subGroup.equalsIgnoreCase(String.valueOf(ext.get("productTypeSubGroup")))) {
|
||||
continue;
|
||||
}
|
||||
product.setAdvanceExtensions(new LinkedHashMap<>(ext));
|
||||
result.add(product);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A product is active from its start date until its close date; either end may be open. */
|
||||
private static boolean isActiveOn(final LoanProductData product, final LocalDate date) {
|
||||
final LocalDate start = product.getStartDate();
|
||||
final LocalDate close = product.getCloseDate();
|
||||
return (start == null || !start.isAfter(date)) && (close == null || !close.isBefore(date));
|
||||
}
|
||||
|
||||
private static String trimToNull(final String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -384,6 +436,9 @@ public class LoanProductsApiResource {
|
||||
if (settings.isTemplate()) {
|
||||
loanProduct = handleTemplate(loanProduct);
|
||||
}
|
||||
// "Advances A/C" satellite fields, so the edit form can repopulate. Set last: the accounting and template
|
||||
// steps above return fresh LoanProductData copies that would otherwise drop it. Empty map for a standard product.
|
||||
loanProduct.setAdvanceExtensions(this.loanProductAdvanceExtService.retrieveAsMap(productId));
|
||||
return this.toApiJsonSerializer.serialize(settings, loanProduct, LOAN_PRODUCT_DATA_PARAMETERS);
|
||||
}
|
||||
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.JsonSerializer;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* REST surface for the Loan Interest Rate Register. Mirrors
|
||||
* {@code FixedDepositProductsBaseInterestRatesApiResource}: a GET list for the Version History
|
||||
* accordion, a GET /active for the current rate chart, a GET /{id} for one specific version, and
|
||||
* a POST that goes through Fineract's maker-checker pipeline.
|
||||
*
|
||||
* Reads are gated by the pre-existing {@code LOANPRODUCT} resource; writes by the two seeded
|
||||
* permissions {@code CREATE_LOANBASEINTERESTRATE} (maker) and {@code CREATE_LOANBASEINTERESTRATE_CHECKER}.
|
||||
*/
|
||||
@Path("/v1/loanproducts/baseinterestrates")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class LoanProductsBaseInterestRatesApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "LOANPRODUCT";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final LoanRateRegisterReadPlatformService readService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
private final Gson gson = new GsonBuilder()
|
||||
.registerTypeAdapter(LocalDate.class,
|
||||
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
|
||||
.registerTypeAdapter(LocalDateTime.class,
|
||||
(JsonSerializer<LocalDateTime>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)))
|
||||
.create();
|
||||
|
||||
/** Full Version History — all effective versions, newest first. */
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get all Loan Interest Rate Register versions", description = "Returns the full history of rate register versions, newest first.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public String retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final List<LoanRateRegisterData> data = this.readService.retrieveAll();
|
||||
return this.gson.toJson(data);
|
||||
}
|
||||
|
||||
/** Master list of particulars (loan types) that make up the chart rows. Literal path wins over {id}. */
|
||||
@GET
|
||||
@Path("products")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get Loan Interest Rate Register particulars", description = "Returns the master list of loan types (chart rows) with their mapped GL account id, in chart order.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public String retrieveProducts() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return this.gson.toJson(this.readService.retrieveProducts());
|
||||
}
|
||||
|
||||
/** The currently in-force version, with its full chart + CIBIL. */
|
||||
@GET
|
||||
@Path("active")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get the active Loan Interest Rate Register", description = "Returns the version flagged isActive=true, or null when none has been approved yet.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public String retrieveActive() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Optional<LoanRateRegisterData> data = this.readService.retrieveActive();
|
||||
return this.gson.toJson(data.orElse(null));
|
||||
}
|
||||
|
||||
/** One specific version by id — used by the Compare screen for the "will be deactivated" baseline. */
|
||||
@GET
|
||||
@Path("{id}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get a specific rate register version", description = "Returns the full chart + CIBIL bands for the given version id.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK"), @ApiResponse(responseCode = "404", description = "Not found") })
|
||||
public String retrieveById(@PathParam("id") final Long id) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Optional<LoanRateRegisterData> data = this.readService.retrieveById(id);
|
||||
return this.gson.toJson(data.orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new version. If {@code CREATE_LOANBASEINTERESTRATE.can_maker_checker=true}, this
|
||||
* returns {@code rollbackTransaction=true} and the row sits in {@code m_portfolio_command_source}
|
||||
* until a checker approves it from the Approvals tab. Otherwise it commits immediately.
|
||||
*/
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Submit a new Loan Interest Rate Register version", description = "Creates a new version of the loan interest rate register. Deactivates the current active version on approval.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public CommandProcessingResult submitRateRegister(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.createLoanBaseInterestRate()
|
||||
.withJson(apiRequestBodyAsJson)
|
||||
.build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JSON shape expected by {@code POST /loanproducts/baseinterestrates}. Deserialised by Gson in the
|
||||
* write service. One request = one proposed new version of the rate register.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterSaveRequest {
|
||||
|
||||
private String name;
|
||||
/** ISO date "YYYY-MM-DD". Defaults to today when omitted. */
|
||||
private String effectiveFromDate;
|
||||
private String currencyCode;
|
||||
private Integer fallbackNoCibilSlab;
|
||||
private String fallbackBelowMin;
|
||||
private String note;
|
||||
|
||||
/**
|
||||
* Particulars (chart rows) with their GL. A row whose srNo is not yet in the master is created on
|
||||
* approval and must carry a glAccountId; for an existing row a changed glAccountId/name updates the master.
|
||||
*/
|
||||
private List<ProductRequest> products;
|
||||
private List<SlabRequest> slabs;
|
||||
private List<CibilSlabRequest> cibilSlabs;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public static class ProductRequest {
|
||||
private Integer srNo;
|
||||
private String name;
|
||||
private Long glAccountId;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public static class SlabRequest {
|
||||
private Integer productSrNo;
|
||||
private String productName;
|
||||
private Integer slabOrder;
|
||||
private String slabLabel;
|
||||
private BigDecimal amountUpto;
|
||||
private BigDecimal rateValue;
|
||||
private String rateFormula;
|
||||
private Boolean isFormula;
|
||||
private Boolean isNa;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public static class CibilSlabRequest {
|
||||
private Integer scoreFrom;
|
||||
private Integer scoreTo;
|
||||
private String tier;
|
||||
private String slabType;
|
||||
private BigDecimal adjustmentPct;
|
||||
private Integer displayOrder;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One CIBIL/credit-score band of a rate register version, used in the response payload.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterCibilSlabData {
|
||||
|
||||
private Long id;
|
||||
private Integer scoreFrom;
|
||||
private Integer scoreTo;
|
||||
private String tier;
|
||||
private String slabType;
|
||||
private BigDecimal adjustmentPct;
|
||||
private Integer displayOrder;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Response DTO for one effective version of the Loan Interest Rate Register. Header fields +
|
||||
* nested chart cells + CIBIL bands.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterData {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private LocalDate effectiveFromDate;
|
||||
private LocalDate effectiveToDate;
|
||||
private String currencyCode;
|
||||
private Boolean isActive;
|
||||
private Integer fallbackNoCibilSlab;
|
||||
private String fallbackBelowMin;
|
||||
private String note;
|
||||
private Long createdById;
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private List<LoanRateRegisterSlabData> slabs;
|
||||
private List<LoanRateRegisterCibilSlabData> cibilSlabs;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** Response DTO for one particular (loan type) of the Loan Interest Rate Register. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterProductData {
|
||||
|
||||
private Long id;
|
||||
private Integer srNo;
|
||||
private String name;
|
||||
/** Null until the bank maps the particular to a GL account. */
|
||||
private Long glAccountId;
|
||||
private Boolean isActive;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One cell of a rate register version — same shape as the entity but a POJO for the response.
|
||||
* Serialised through Gson in the REST resource.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterSlabData {
|
||||
|
||||
private Long id;
|
||||
private Long productId;
|
||||
private Integer productSrNo;
|
||||
private String productName;
|
||||
private Integer slabOrder;
|
||||
private String slabLabel;
|
||||
private BigDecimal amountUpto;
|
||||
private BigDecimal rateValue;
|
||||
private String rateFormula;
|
||||
private Boolean isFormula;
|
||||
private Boolean isNa;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Dispatched by Fineract when a {@code LOANBASEINTERESTRATE} / {@code CREATE} command is executed —
|
||||
* on first submit (maker-checker off) or on checker approval (maker-checker on). The {@code @CommandType}
|
||||
* entity string here MUST match the one registered by {@code CommandWrapperBuilder.createLoanBaseInterestRate()}
|
||||
* and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}.
|
||||
*/
|
||||
@Service
|
||||
@CommandType(entity = "LOANBASEINTERESTRATE", action = "CREATE")
|
||||
@RequiredArgsConstructor
|
||||
public class CreateLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final LoanRateRegisterWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.saveRateRegister(command);
|
||||
}
|
||||
}
|
||||
+8
@@ -72,6 +72,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.InterestRecalculationCom
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductValueConditionType;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRescheduleStrategyMethod;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
|
||||
@@ -212,6 +213,13 @@ public final class LoanProductDataValidator {
|
||||
LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME //
|
||||
));
|
||||
|
||||
static {
|
||||
// "Advances A/C" satellite params are persisted by LoanProductAdvanceExtService, which owns the
|
||||
// authoritative list of their names — added here rather than restated so the two cannot drift, and so
|
||||
// the unsupported-parameter check keeps rejecting genuine typos.
|
||||
SUPPORTED_PARAMETERS.addAll(Arrays.asList(LoanProductAdvanceExtService.ADVANCE_PARAMETERS));
|
||||
}
|
||||
|
||||
private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName,
|
||||
LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName,
|
||||
LoanProductConstants.interestCalculationPeriodTypeParamName, LoanProductConstants.inArrearsToleranceParamName,
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.serialization;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Validates a {@link LoanRateRegisterSaveRequest} before the write service touches the DB. Keeps
|
||||
* the write service free of guard clauses and surfaces every error up front rather than failing on
|
||||
* the first one — Fineract convention.
|
||||
*/
|
||||
@Component
|
||||
public class LoanRateRegisterDataValidator {
|
||||
|
||||
private static final String RESOURCE_NAME = "loanRateRegister";
|
||||
|
||||
public void validate(final LoanRateRegisterSaveRequest req) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
|
||||
if (req == null) {
|
||||
errors.add(error("payload", "payload.required", "Request body is required"));
|
||||
raise(errors);
|
||||
}
|
||||
|
||||
// Header fields
|
||||
if (isBlank(req.getName())) {
|
||||
errors.add(error("name", "name.required", "Register name is required"));
|
||||
} else if (req.getName().length() > 200) {
|
||||
errors.add(error("name", "name.exceeds.max.length", "Register name must be at most 200 characters"));
|
||||
}
|
||||
if (req.getFallbackNoCibilSlab() != null && req.getFallbackNoCibilSlab() < 1) {
|
||||
errors.add(error("fallbackNoCibilSlab", "fallback.no.cibil.slab.invalid", "Fallback slab pointer must be >= 1"));
|
||||
}
|
||||
if (req.getFallbackBelowMin() != null && !req.getFallbackBelowMin().equals("use-min-slab")
|
||||
&& !req.getFallbackBelowMin().equals("block")) {
|
||||
errors.add(error("fallbackBelowMin", "fallback.below.min.invalid",
|
||||
"fallbackBelowMin must be 'use-min-slab' or 'block'"));
|
||||
}
|
||||
|
||||
// Chart cells
|
||||
final List<SlabRequest> slabs = req.getSlabs();
|
||||
if (slabs == null || slabs.isEmpty()) {
|
||||
errors.add(error("slabs", "slabs.required", "At least one rate chart cell is required"));
|
||||
} else {
|
||||
for (int i = 0; i < slabs.size(); i++) {
|
||||
validateSlab(slabs.get(i), i, errors);
|
||||
}
|
||||
}
|
||||
|
||||
// CIBIL bands
|
||||
final List<CibilSlabRequest> cibil = req.getCibilSlabs();
|
||||
if (cibil == null || cibil.isEmpty()) {
|
||||
errors.add(error("cibilSlabs", "cibilSlabs.required", "At least one CIBIL slab is required"));
|
||||
} else {
|
||||
validateCibilCoverage(cibil, errors);
|
||||
}
|
||||
|
||||
raise(errors);
|
||||
}
|
||||
|
||||
private void validateSlab(final SlabRequest s, final int idx, final List<ApiParameterError> errors) {
|
||||
final String prefix = "slabs[" + idx + "]";
|
||||
if (s.getProductSrNo() == null) {
|
||||
errors.add(error(prefix + ".productSrNo", "productSrNo.required", "productSrNo is required"));
|
||||
}
|
||||
if (isBlank(s.getProductName())) {
|
||||
errors.add(error(prefix + ".productName", "productName.required", "productName is required"));
|
||||
}
|
||||
if (s.getSlabOrder() == null) {
|
||||
errors.add(error(prefix + ".slabOrder", "slabOrder.required", "slabOrder is required"));
|
||||
}
|
||||
if (isBlank(s.getSlabLabel())) {
|
||||
errors.add(error(prefix + ".slabLabel", "slabLabel.required", "slabLabel is required"));
|
||||
}
|
||||
// Exactly one of: rateValue populated, rateFormula populated, isNa=true. Both-null-and-not-NA is invalid.
|
||||
final boolean hasValue = s.getRateValue() != null;
|
||||
final boolean hasFormula = s.getRateFormula() != null && !s.getRateFormula().isBlank();
|
||||
final boolean na = Boolean.TRUE.equals(s.getIsNa());
|
||||
final int populated = (hasValue ? 1 : 0) + (hasFormula ? 1 : 0) + (na ? 1 : 0);
|
||||
if (populated == 0) {
|
||||
errors.add(error(prefix, "cell.empty",
|
||||
"Cell must have a rateValue, a rateFormula, or isNa=true"));
|
||||
} else if (populated > 1) {
|
||||
errors.add(error(prefix, "cell.ambiguous",
|
||||
"Cell must have exactly one of rateValue, rateFormula, or isNa"));
|
||||
}
|
||||
if (hasFormula && !Boolean.TRUE.equals(s.getIsFormula())) {
|
||||
errors.add(error(prefix + ".isFormula", "isFormula.mismatch",
|
||||
"isFormula must be true when rateFormula is set"));
|
||||
}
|
||||
}
|
||||
|
||||
/** CIBIL bands must cover 0..900 contiguously and must not overlap. */
|
||||
private void validateCibilCoverage(final List<CibilSlabRequest> cibil, final List<ApiParameterError> errors) {
|
||||
final List<CibilSlabRequest> sorted = new ArrayList<>(cibil);
|
||||
sorted.sort((a, b) -> Integer.compare(nz(a.getScoreFrom()), nz(b.getScoreFrom())));
|
||||
final Set<Integer> seenOrders = new TreeSet<>();
|
||||
int expectedNextFrom = 0;
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
final CibilSlabRequest c = sorted.get(i);
|
||||
final String prefix = "cibilSlabs[" + i + "]";
|
||||
if (c.getScoreFrom() == null || c.getScoreTo() == null) {
|
||||
errors.add(error(prefix, "cibil.range.required", "scoreFrom and scoreTo are required"));
|
||||
continue;
|
||||
}
|
||||
if (c.getScoreFrom() > c.getScoreTo()) {
|
||||
errors.add(error(prefix, "cibil.range.inverted", "scoreFrom must be <= scoreTo"));
|
||||
}
|
||||
if (c.getScoreFrom() != expectedNextFrom) {
|
||||
errors.add(error(prefix, "cibil.range.gap_or_overlap",
|
||||
"CIBIL bands must be contiguous starting from 0 (expected " + expectedNextFrom
|
||||
+ ", got " + c.getScoreFrom() + ")"));
|
||||
}
|
||||
if (isBlank(c.getTier())) {
|
||||
errors.add(error(prefix + ".tier", "tier.required", "tier is required"));
|
||||
}
|
||||
if (c.getAdjustmentPct() == null) {
|
||||
errors.add(error(prefix + ".adjustmentPct", "adjustmentPct.required", "adjustmentPct is required"));
|
||||
}
|
||||
if (c.getDisplayOrder() == null || !seenOrders.add(c.getDisplayOrder())) {
|
||||
errors.add(error(prefix + ".displayOrder", "displayOrder.invalid",
|
||||
"displayOrder is required and must be unique"));
|
||||
}
|
||||
expectedNextFrom = c.getScoreTo() + 1;
|
||||
}
|
||||
if (!sorted.isEmpty() && expectedNextFrom <= 900) {
|
||||
errors.add(error("cibilSlabs", "cibil.coverage.incomplete",
|
||||
"CIBIL bands must cover up to 900 (last to was " + (expectedNextFrom - 1) + ")"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ApiParameterError error(final String field, final String code, final String msg) {
|
||||
return ApiParameterError.parameterError("validation." + RESOURCE_NAME + "." + code, msg, field);
|
||||
}
|
||||
|
||||
private static void raise(final List<ApiParameterError> errors) {
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg." + RESOURCE_NAME + ".failed",
|
||||
"Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(final String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
private static int nz(final Integer n) {
|
||||
return n != null ? n : 0;
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Persists and reads the "Advances A/C" satellite ({@link LoanProductAdvanceExt}).
|
||||
*
|
||||
* Only touched for an Advances product: a standard loan product carries none of {@link #ADVANCE_PARAMETERS},
|
||||
* so {@link #saveOrUpdate} early-returns and never writes an empty row. Called from inside the loan-product
|
||||
* create/update transaction, so a failure here rolls the whole product back.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanProductAdvanceExtService {
|
||||
|
||||
private final LoanProductAdvanceExtRepository repository;
|
||||
|
||||
/**
|
||||
* Every Advance parameter name. Shared with {@code LoanProductDataValidator} (added to its
|
||||
* SUPPORTED_PARAMETERS) so the two can never disagree about what is accepted. These are the
|
||||
* satellite-only fields; core fields (principal, rate, tenure, npaDays, delinquencyBucketId,
|
||||
* graceOnArrearsAgeing, inArrearsTolerance, accountingRule...) are handled by the standard product.
|
||||
*/
|
||||
public static final String[] ADVANCE_PARAMETERS = { "productTypeGroup", "productTypeSubGroup", "glAccount",
|
||||
"loanProductCategory", "prioritySector", "cibilAccountType", "kfsEnabled", "aprEnabled", "facilityType",
|
||||
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
|
||||
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
|
||||
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
|
||||
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
|
||||
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
|
||||
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
|
||||
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
|
||||
"shareCapitalGl",
|
||||
// security & lien extras
|
||||
"eligibleDepositProducts", "lienTenureNotExceedMaturity", "lienAutoReleaseOnClosure",
|
||||
"lienAllowMultipleDeposits", "lienAdjustFromMaturity", "lienAllowThirdParty", "lienAllowRenewalUnderLien",
|
||||
// limit & tenure extras
|
||||
"repaymentType", "appropriationOrder", "debitBalanceAllow", "allowDebitAfterDueDate", "branchLevelAccountType",
|
||||
// interest extras
|
||||
"interestCalcMethod", "interestOnCreditBalance", "interestPostingDay",
|
||||
// NPA upgrade rules
|
||||
"npaUpgradeOnlyAllArrears", "npaKeepStandardIfDepositCovers",
|
||||
// cheque book full config
|
||||
"chequeBookSizes", "chequeMaxBooksOutstanding", "chequeReissueWhenLeaves", "chequeFirstBookFree",
|
||||
"chequeFreeBooksPerYear", "chequeNamePrinted", "chequePrintDescription", "chequeStopPaymentAllowed",
|
||||
"chequeMaintainStockRegister", "chequeAllowWithdrawalSlip", "chequeBlockNpa", "chequeBlockLimitExpired",
|
||||
"chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased",
|
||||
// Loans A/C (EMI) wizard fields
|
||||
"securityMandatory", "guarantorsRequired", "coApplicant", "thirdPartySecurity",
|
||||
"legalOpinionRequired", "propertyInsuranceRequired", "registerCharge", "releaseSecurityOnClosure",
|
||||
"allowMultipleSecurities", "allowThirdPartyDeposit", "minAge", "maxAgeAtMaturity",
|
||||
"minCibil", "minMonthlyIncome", "maxFoir", "maxLoansPerCustomer",
|
||||
"disbursementType", "disbursementMode", "undisbursedInterest", "preEmiInterest",
|
||||
"emiDueDay", "holidayRule", "moratoriumMonths", "moratoriumInterest",
|
||||
"variableInstallments", "repaymentMode", "linkedAccountType", "pdcCollection",
|
||||
"mandateRequired", "advanceEmi", "rateType", "registerParticular",
|
||||
"applyCibilAdjustment", "rateResolvedAt", "calcPeriod", "interestStartsFrom",
|
||||
"compounding", "benchmark", "resetFrequency", "resetChange",
|
||||
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
|
||||
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
|
||||
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
|
||||
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
|
||||
|
||||
/**
|
||||
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
|
||||
* caller can fold them into the loan-product audit {@code changes} map.
|
||||
*/
|
||||
public Map<String, Object> saveOrUpdate(final LoanProduct loanProduct, final JsonCommand command) {
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
if (!hasAnyAdvanceParameter(command)) {
|
||||
return changes;
|
||||
}
|
||||
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
|
||||
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
|
||||
applyScalarFields(ext, command, changes);
|
||||
this.repository.save(ext);
|
||||
return changes;
|
||||
}
|
||||
|
||||
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
|
||||
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
|
||||
.orElseGet(LinkedHashMap::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every product's satellite in one query, keyed by loan product id. Used by the product list so a screen can
|
||||
* show and filter products by category and type without one extra call per product.
|
||||
*/
|
||||
public Map<Long, Map<String, Object>> retrieveAllAsMap() {
|
||||
final Map<Long, Map<String, Object>> byProduct = new LinkedHashMap<>();
|
||||
for (final LoanProductAdvanceExt ext : this.repository.findAll()) {
|
||||
byProduct.put(ext.getLoanProductId(), toMap(ext));
|
||||
}
|
||||
return byProduct;
|
||||
}
|
||||
|
||||
private boolean hasAnyAdvanceParameter(final JsonCommand command) {
|
||||
for (final String param : ADVANCE_PARAMETERS) {
|
||||
if (command.parameterExists(param)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void applyScalarFields(final LoanProductAdvanceExt ext, final JsonCommand command,
|
||||
final Map<String, Object> changes) {
|
||||
setString(command, "productTypeGroup", ext::getProductTypeGroup, ext::setProductTypeGroup, changes);
|
||||
setString(command, "productTypeSubGroup", ext::getProductTypeSubGroup, ext::setProductTypeSubGroup, changes);
|
||||
setLong(command, "glAccount", ext::getGlAccountId, ext::setGlAccountId, changes);
|
||||
setString(command, "loanProductCategory", ext::getLoanProductCategory, ext::setLoanProductCategory, changes);
|
||||
setString(command, "prioritySector", ext::getPrioritySector, ext::setPrioritySector, changes);
|
||||
setString(command, "cibilAccountType", ext::getCibilAccountType, ext::setCibilAccountType, changes);
|
||||
setBoolean(command, "kfsEnabled", ext::getKfsEnabled, ext::setKfsEnabled, changes);
|
||||
setBoolean(command, "aprEnabled", ext::getAprEnabled, ext::setAprEnabled, changes);
|
||||
setString(command, "facilityType", ext::getFacilityType, ext::setFacilityType, changes);
|
||||
setString(command, "securityType", ext::getSecurityType, ext::setSecurityType, changes);
|
||||
setString(command, "chargeType", ext::getChargeType, ext::setChargeType, changes);
|
||||
setString(command, "eligibleAmountBasis", ext::getEligibleAmountBasis, ext::setEligibleAmountBasis, changes);
|
||||
setDecimal(command, "margin", ext::getMarginPct, ext::setMarginPct, changes);
|
||||
setDecimal(command, "ltv", ext::getLtvPct, ext::setLtvPct, changes);
|
||||
setString(command, "dpMethod", ext::getDpMethod, ext::setDpMethod, changes);
|
||||
setBoolean(command, "lienRequired", ext::getLienRequired, ext::setLienRequired, changes);
|
||||
setString(command, "lienMarkingStage", ext::getLienMarkingStage, ext::setLienMarkingStage, changes);
|
||||
setBoolean(command, "lienCertificateRequired", ext::getLienCertificateRequired, ext::setLienCertificateRequired,
|
||||
changes);
|
||||
setString(command, "lienCertificatePrefix", ext::getLienCertificatePrefix, ext::setLienCertificatePrefix,
|
||||
changes);
|
||||
setBoolean(command, "printCertificateOnDisbursement", ext::getPrintCertificateOnDisbursement,
|
||||
ext::setPrintCertificateOnDisbursement, changes);
|
||||
setString(command, "interestBasis", ext::getInterestBasis, ext::setInterestBasis, changes);
|
||||
setDecimal(command, "spread", ext::getSpreadPct, ext::setSpreadPct, changes);
|
||||
setString(command, "interestChargedOn", ext::getInterestChargedOn, ext::setInterestChargedOn, changes);
|
||||
setString(command, "interestPostingFrequency", ext::getInterestPostingFrequency,
|
||||
ext::setInterestPostingFrequency, changes);
|
||||
setBoolean(command, "penalApplicable", ext::getPenalApplicable, ext::setPenalApplicable, changes);
|
||||
setDecimal(command, "penalRate", ext::getPenalRatePct, ext::setPenalRatePct, changes);
|
||||
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
|
||||
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
|
||||
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
|
||||
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
|
||||
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
|
||||
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
|
||||
setInteger(command, "renewalPeriodMonths", ext::getRenewalPeriodMonths, ext::setRenewalPeriodMonths, changes);
|
||||
setInteger(command, "renewalGraceDays", ext::getRenewalGraceDays, ext::setRenewalGraceDays, changes);
|
||||
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
|
||||
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
|
||||
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
|
||||
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
|
||||
changes);
|
||||
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
|
||||
setInteger(command, "stockStatementFreqMonths", ext::getStockStatementFreqMonths,
|
||||
ext::setStockStatementFreqMonths, changes);
|
||||
setBoolean(command, "chequeBookEnabled", ext::getChequeBookEnabled, ext::setChequeBookEnabled, changes);
|
||||
setString(command, "chequeType", ext::getChequeType, ext::setChequeType, changes);
|
||||
setInteger(command, "defaultChequeBookSize", ext::getDefaultChequeBookSize, ext::setDefaultChequeBookSize,
|
||||
changes);
|
||||
setDecimal(command, "ppsThreshold", ext::getPpsThreshold, ext::setPpsThreshold, changes);
|
||||
setDecimal(command, "ppsMandatoryAbove", ext::getPpsMandatoryAbove, ext::setPpsMandatoryAbove, changes);
|
||||
setLong(command, "shareCapitalGl", ext::getShareCapitalGlId, ext::setShareCapitalGlId, changes);
|
||||
// security & lien extras
|
||||
setString(command, "eligibleDepositProducts", ext::getEligibleDepositProducts, ext::setEligibleDepositProducts,
|
||||
changes);
|
||||
setBoolean(command, "lienTenureNotExceedMaturity", ext::getLienTenureNotExceedMaturity,
|
||||
ext::setLienTenureNotExceedMaturity, changes);
|
||||
setBoolean(command, "lienAutoReleaseOnClosure", ext::getLienAutoReleaseOnClosure,
|
||||
ext::setLienAutoReleaseOnClosure, changes);
|
||||
setBoolean(command, "lienAllowMultipleDeposits", ext::getLienAllowMultipleDeposits,
|
||||
ext::setLienAllowMultipleDeposits, changes);
|
||||
setBoolean(command, "lienAdjustFromMaturity", ext::getLienAdjustFromMaturity, ext::setLienAdjustFromMaturity,
|
||||
changes);
|
||||
setBoolean(command, "lienAllowThirdParty", ext::getLienAllowThirdParty, ext::setLienAllowThirdParty, changes);
|
||||
setBoolean(command, "lienAllowRenewalUnderLien", ext::getLienAllowRenewalUnderLien,
|
||||
ext::setLienAllowRenewalUnderLien, changes);
|
||||
// limit & tenure extras
|
||||
setString(command, "repaymentType", ext::getRepaymentType, ext::setRepaymentType, changes);
|
||||
setString(command, "appropriationOrder", ext::getAppropriationOrder, ext::setAppropriationOrder, changes);
|
||||
setBoolean(command, "debitBalanceAllow", ext::getDebitBalanceAllow, ext::setDebitBalanceAllow, changes);
|
||||
setBoolean(command, "allowDebitAfterDueDate", ext::getAllowDebitAfterDueDate, ext::setAllowDebitAfterDueDate,
|
||||
changes);
|
||||
setBoolean(command, "branchLevelAccountType", ext::getBranchLevelAccountType, ext::setBranchLevelAccountType,
|
||||
changes);
|
||||
// interest extras
|
||||
setString(command, "interestCalcMethod", ext::getInterestCalcMethod, ext::setInterestCalcMethod, changes);
|
||||
setBoolean(command, "interestOnCreditBalance", ext::getInterestOnCreditBalance, ext::setInterestOnCreditBalance,
|
||||
changes);
|
||||
setString(command, "interestPostingDay", ext::getInterestPostingDay, ext::setInterestPostingDay, changes);
|
||||
// NPA upgrade rules
|
||||
setBoolean(command, "npaUpgradeOnlyAllArrears", ext::getNpaUpgradeOnlyAllArrears,
|
||||
ext::setNpaUpgradeOnlyAllArrears, changes);
|
||||
setBoolean(command, "npaKeepStandardIfDepositCovers", ext::getNpaKeepStandardIfDepositCovers,
|
||||
ext::setNpaKeepStandardIfDepositCovers, changes);
|
||||
// cheque book full config
|
||||
setString(command, "chequeBookSizes", ext::getChequeBookSizes, ext::setChequeBookSizes, changes);
|
||||
setInteger(command, "chequeMaxBooksOutstanding", ext::getChequeMaxBooksOutstanding,
|
||||
ext::setChequeMaxBooksOutstanding, changes);
|
||||
setInteger(command, "chequeReissueWhenLeaves", ext::getChequeReissueWhenLeaves, ext::setChequeReissueWhenLeaves,
|
||||
changes);
|
||||
setBoolean(command, "chequeFirstBookFree", ext::getChequeFirstBookFree, ext::setChequeFirstBookFree, changes);
|
||||
setInteger(command, "chequeFreeBooksPerYear", ext::getChequeFreeBooksPerYear, ext::setChequeFreeBooksPerYear,
|
||||
changes);
|
||||
setString(command, "chequeNamePrinted", ext::getChequeNamePrinted, ext::setChequeNamePrinted, changes);
|
||||
setString(command, "chequePrintDescription", ext::getChequePrintDescription, ext::setChequePrintDescription,
|
||||
changes);
|
||||
setBoolean(command, "chequeStopPaymentAllowed", ext::getChequeStopPaymentAllowed,
|
||||
ext::setChequeStopPaymentAllowed, changes);
|
||||
setBoolean(command, "chequeMaintainStockRegister", ext::getChequeMaintainStockRegister,
|
||||
ext::setChequeMaintainStockRegister, changes);
|
||||
setBoolean(command, "chequeAllowWithdrawalSlip", ext::getChequeAllowWithdrawalSlip,
|
||||
ext::setChequeAllowWithdrawalSlip, changes);
|
||||
setBoolean(command, "chequeBlockNpa", ext::getChequeBlockNpa, ext::setChequeBlockNpa, changes);
|
||||
setBoolean(command, "chequeBlockLimitExpired", ext::getChequeBlockLimitExpired, ext::setChequeBlockLimitExpired,
|
||||
changes);
|
||||
setBoolean(command, "chequeBlockOverDp", ext::getChequeBlockOverDp, ext::setChequeBlockOverDp, changes);
|
||||
setBoolean(command, "chequeBlockFrozen", ext::getChequeBlockFrozen, ext::setChequeBlockFrozen, changes);
|
||||
setBoolean(command, "chequeBlockLienReleased", ext::getChequeBlockLienReleased, ext::setChequeBlockLienReleased,
|
||||
changes);
|
||||
// Loans A/C (EMI) wizard fields
|
||||
setString(command, "securityMandatory", ext::getSecurityMandatory, ext::setSecurityMandatory, changes);
|
||||
setInteger(command, "guarantorsRequired", ext::getGuarantorsRequired, ext::setGuarantorsRequired, changes);
|
||||
setString(command, "coApplicant", ext::getCoApplicant, ext::setCoApplicant, changes);
|
||||
setString(command, "thirdPartySecurity", ext::getThirdPartySecurity, ext::setThirdPartySecurity, changes);
|
||||
setBoolean(command, "legalOpinionRequired", ext::getLegalOpinionRequired, ext::setLegalOpinionRequired, changes);
|
||||
setBoolean(command, "propertyInsuranceRequired", ext::getPropertyInsuranceRequired, ext::setPropertyInsuranceRequired, changes);
|
||||
setBoolean(command, "registerCharge", ext::getRegisterCharge, ext::setRegisterCharge, changes);
|
||||
setBoolean(command, "releaseSecurityOnClosure", ext::getReleaseSecurityOnClosure, ext::setReleaseSecurityOnClosure, changes);
|
||||
setBoolean(command, "allowMultipleSecurities", ext::getAllowMultipleSecurities, ext::setAllowMultipleSecurities, changes);
|
||||
setBoolean(command, "allowThirdPartyDeposit", ext::getAllowThirdPartyDeposit, ext::setAllowThirdPartyDeposit, changes);
|
||||
setInteger(command, "minAge", ext::getMinAge, ext::setMinAge, changes);
|
||||
setInteger(command, "maxAgeAtMaturity", ext::getMaxAgeAtMaturity, ext::setMaxAgeAtMaturity, changes);
|
||||
setInteger(command, "minCibil", ext::getMinCibil, ext::setMinCibil, changes);
|
||||
setDecimal(command, "minMonthlyIncome", ext::getMinMonthlyIncome, ext::setMinMonthlyIncome, changes);
|
||||
setDecimal(command, "maxFoir", ext::getMaxFoir, ext::setMaxFoir, changes);
|
||||
setInteger(command, "maxLoansPerCustomer", ext::getMaxLoansPerCustomer, ext::setMaxLoansPerCustomer, changes);
|
||||
setString(command, "disbursementType", ext::getDisbursementType, ext::setDisbursementType, changes);
|
||||
setString(command, "disbursementMode", ext::getDisbursementMode, ext::setDisbursementMode, changes);
|
||||
setString(command, "undisbursedInterest", ext::getUndisbursedInterest, ext::setUndisbursedInterest, changes);
|
||||
setBoolean(command, "preEmiInterest", ext::getPreEmiInterest, ext::setPreEmiInterest, changes);
|
||||
setString(command, "emiDueDay", ext::getEmiDueDay, ext::setEmiDueDay, changes);
|
||||
setString(command, "holidayRule", ext::getHolidayRule, ext::setHolidayRule, changes);
|
||||
setInteger(command, "moratoriumMonths", ext::getMoratoriumMonths, ext::setMoratoriumMonths, changes);
|
||||
setString(command, "moratoriumInterest", ext::getMoratoriumInterest, ext::setMoratoriumInterest, changes);
|
||||
setBoolean(command, "variableInstallments", ext::getVariableInstallments, ext::setVariableInstallments, changes);
|
||||
setString(command, "repaymentMode", ext::getRepaymentMode, ext::setRepaymentMode, changes);
|
||||
setString(command, "linkedAccountType", ext::getLinkedAccountType, ext::setLinkedAccountType, changes);
|
||||
setString(command, "pdcCollection", ext::getPdcCollection, ext::setPdcCollection, changes);
|
||||
setBoolean(command, "mandateRequired", ext::getMandateRequired, ext::setMandateRequired, changes);
|
||||
setString(command, "advanceEmi", ext::getAdvanceEmi, ext::setAdvanceEmi, changes);
|
||||
setString(command, "rateType", ext::getRateType, ext::setRateType, changes);
|
||||
setString(command, "registerParticular", ext::getRegisterParticular, ext::setRegisterParticular, changes);
|
||||
setBoolean(command, "applyCibilAdjustment", ext::getApplyCibilAdjustment, ext::setApplyCibilAdjustment, changes);
|
||||
setString(command, "rateResolvedAt", ext::getRateResolvedAt, ext::setRateResolvedAt, changes);
|
||||
setString(command, "calcPeriod", ext::getCalcPeriod, ext::setCalcPeriod, changes);
|
||||
setString(command, "interestStartsFrom", ext::getInterestStartsFrom, ext::setInterestStartsFrom, changes);
|
||||
setString(command, "compounding", ext::getCompounding, ext::setCompounding, changes);
|
||||
setString(command, "benchmark", ext::getBenchmark, ext::setBenchmark, changes);
|
||||
setString(command, "resetFrequency", ext::getResetFrequency, ext::setResetFrequency, changes);
|
||||
setString(command, "resetChange", ext::getResetChange, ext::setResetChange, changes);
|
||||
setBoolean(command, "partPrepaymentAllowed", ext::getPartPrepaymentAllowed, ext::setPartPrepaymentAllowed, changes);
|
||||
setDecimal(command, "minPartPayment", ext::getMinPartPayment, ext::setMinPartPayment, changes);
|
||||
setString(command, "allowedOn", ext::getAllowedOn, ext::setAllowedOn, changes);
|
||||
setString(command, "partPaymentsPerYear", ext::getPartPaymentsPerYear, ext::setPartPaymentsPerYear, changes);
|
||||
setString(command, "partPrepaymentMode", ext::getPartPrepaymentMode, ext::setPartPrepaymentMode, changes);
|
||||
setDecimal(command, "foreclosureFloatingIndividual", ext::getForeclosureFloatingIndividual, ext::setForeclosureFloatingIndividual, changes);
|
||||
setDecimal(command, "foreclosureFixedIndividual", ext::getForeclosureFixedIndividual, ext::setForeclosureFixedIndividual, changes);
|
||||
setDecimal(command, "foreclosureNonIndividual", ext::getForeclosureNonIndividual, ext::setForeclosureNonIndividual, changes);
|
||||
setDecimal(command, "foreclosureTakeover", ext::getForeclosureTakeover, ext::setForeclosureTakeover, changes);
|
||||
setInteger(command, "foreclosureQuoteValidDays", ext::getForeclosureQuoteValidDays, ext::setForeclosureQuoteValidDays, changes);
|
||||
setBoolean(command, "autoCloseOnZeroBalance", ext::getAutoCloseOnZeroBalance, ext::setAutoCloseOnZeroBalance, changes);
|
||||
setString(command, "nocLetter", ext::getNocLetter, ext::setNocLetter, changes);
|
||||
setString(command, "restructuring", ext::getRestructuring, ext::setRestructuring, changes);
|
||||
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
|
||||
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
|
||||
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
|
||||
}
|
||||
|
||||
// ---- typed setters: only write params that are present, and only report real changes ----------
|
||||
|
||||
private interface Getter<T> {
|
||||
|
||||
T get();
|
||||
}
|
||||
|
||||
private interface Ser<T> {
|
||||
|
||||
void set(T value);
|
||||
}
|
||||
|
||||
private <T> void putIfChanged(final String param, final T oldValue, final T newValue, final Ser<T> setter,
|
||||
final Map<String, Object> changes) {
|
||||
if (differs(oldValue, newValue)) {
|
||||
setter.set(newValue);
|
||||
changes.put(param, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void setString(final JsonCommand command, final String param, final Getter<String> getter,
|
||||
final Ser<String> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.stringValueOfParameterNamedAllowingNull(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private void setLong(final JsonCommand command, final String param, final Getter<Long> getter,
|
||||
final Ser<Long> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.longValueOfParameterNamed(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private void setInteger(final JsonCommand command, final String param, final Getter<Integer> getter,
|
||||
final Ser<Integer> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.integerValueOfParameterNamed(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private void setDecimal(final JsonCommand command, final String param, final Getter<BigDecimal> getter,
|
||||
final Ser<BigDecimal> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.bigDecimalValueOfParameterNamed(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The form stores its yes/no dropdowns as the strings "YES"/"NO" and its checkboxes as real booleans;
|
||||
* accept both shapes so a "NO" is not silently read as null.
|
||||
*/
|
||||
private void setBoolean(final JsonCommand command, final String param, final Getter<Boolean> getter,
|
||||
final Ser<Boolean> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), asBoolean(command.jsonElement(param)), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private static Boolean asBoolean(final JsonElement element) {
|
||||
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (element.getAsJsonPrimitive().isBoolean()) {
|
||||
return element.getAsBoolean();
|
||||
}
|
||||
final String raw = element.getAsString();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return "true".equalsIgnoreCase(raw) || "yes".equalsIgnoreCase(raw) || "1".equals(raw);
|
||||
}
|
||||
|
||||
/** BigDecimal.equals compares scale, so 2.0 vs 2.00 would read as a change; compareTo does not. */
|
||||
private static boolean differs(final Object oldValue, final Object newValue) {
|
||||
if (oldValue instanceof BigDecimal a && newValue instanceof BigDecimal b) {
|
||||
return a.compareTo(b) != 0;
|
||||
}
|
||||
return !Objects.equals(oldValue, newValue);
|
||||
}
|
||||
|
||||
private static Map<String, Object> toMap(final LoanProductAdvanceExt ext) {
|
||||
final Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("productTypeGroup", ext.getProductTypeGroup());
|
||||
map.put("productTypeSubGroup", ext.getProductTypeSubGroup());
|
||||
map.put("glAccount", ext.getGlAccountId());
|
||||
map.put("loanProductCategory", ext.getLoanProductCategory());
|
||||
map.put("prioritySector", ext.getPrioritySector());
|
||||
map.put("cibilAccountType", ext.getCibilAccountType());
|
||||
map.put("kfsEnabled", ext.getKfsEnabled());
|
||||
map.put("aprEnabled", ext.getAprEnabled());
|
||||
map.put("facilityType", ext.getFacilityType());
|
||||
map.put("securityType", ext.getSecurityType());
|
||||
map.put("chargeType", ext.getChargeType());
|
||||
map.put("eligibleAmountBasis", ext.getEligibleAmountBasis());
|
||||
map.put("margin", ext.getMarginPct());
|
||||
map.put("ltv", ext.getLtvPct());
|
||||
map.put("dpMethod", ext.getDpMethod());
|
||||
map.put("lienRequired", ext.getLienRequired());
|
||||
map.put("lienMarkingStage", ext.getLienMarkingStage());
|
||||
map.put("lienCertificateRequired", ext.getLienCertificateRequired());
|
||||
map.put("lienCertificatePrefix", ext.getLienCertificatePrefix());
|
||||
map.put("printCertificateOnDisbursement", ext.getPrintCertificateOnDisbursement());
|
||||
map.put("interestBasis", ext.getInterestBasis());
|
||||
map.put("spread", ext.getSpreadPct());
|
||||
map.put("interestChargedOn", ext.getInterestChargedOn());
|
||||
map.put("interestPostingFrequency", ext.getInterestPostingFrequency());
|
||||
map.put("penalApplicable", ext.getPenalApplicable());
|
||||
map.put("penalRate", ext.getPenalRatePct());
|
||||
map.put("penalAppliedOn", ext.getPenalAppliedOn());
|
||||
map.put("penalGraceDays", ext.getPenalGraceDays());
|
||||
map.put("npaTrigger", ext.getNpaTrigger());
|
||||
map.put("interestAfterNpa", ext.getInterestAfterNpa());
|
||||
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
|
||||
map.put("renewalAllowed", ext.getRenewalAllowed());
|
||||
map.put("renewalPeriodMonths", ext.getRenewalPeriodMonths());
|
||||
map.put("renewalGraceDays", ext.getRenewalGraceDays());
|
||||
map.put("preclosureAllowed", ext.getPreclosureAllowed());
|
||||
map.put("lockInMonths", ext.getLockInMonths());
|
||||
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
|
||||
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
|
||||
map.put("insuranceRequired", ext.getInsuranceRequired());
|
||||
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
|
||||
map.put("chequeBookEnabled", ext.getChequeBookEnabled());
|
||||
map.put("chequeType", ext.getChequeType());
|
||||
map.put("defaultChequeBookSize", ext.getDefaultChequeBookSize());
|
||||
map.put("ppsThreshold", ext.getPpsThreshold());
|
||||
map.put("ppsMandatoryAbove", ext.getPpsMandatoryAbove());
|
||||
map.put("shareCapitalGl", ext.getShareCapitalGlId());
|
||||
map.put("eligibleDepositProducts", ext.getEligibleDepositProducts());
|
||||
map.put("lienTenureNotExceedMaturity", ext.getLienTenureNotExceedMaturity());
|
||||
map.put("lienAutoReleaseOnClosure", ext.getLienAutoReleaseOnClosure());
|
||||
map.put("lienAllowMultipleDeposits", ext.getLienAllowMultipleDeposits());
|
||||
map.put("lienAdjustFromMaturity", ext.getLienAdjustFromMaturity());
|
||||
map.put("lienAllowThirdParty", ext.getLienAllowThirdParty());
|
||||
map.put("lienAllowRenewalUnderLien", ext.getLienAllowRenewalUnderLien());
|
||||
map.put("repaymentType", ext.getRepaymentType());
|
||||
map.put("appropriationOrder", ext.getAppropriationOrder());
|
||||
map.put("debitBalanceAllow", ext.getDebitBalanceAllow());
|
||||
map.put("allowDebitAfterDueDate", ext.getAllowDebitAfterDueDate());
|
||||
map.put("branchLevelAccountType", ext.getBranchLevelAccountType());
|
||||
map.put("interestCalcMethod", ext.getInterestCalcMethod());
|
||||
map.put("interestOnCreditBalance", ext.getInterestOnCreditBalance());
|
||||
map.put("interestPostingDay", ext.getInterestPostingDay());
|
||||
map.put("npaUpgradeOnlyAllArrears", ext.getNpaUpgradeOnlyAllArrears());
|
||||
map.put("npaKeepStandardIfDepositCovers", ext.getNpaKeepStandardIfDepositCovers());
|
||||
map.put("chequeBookSizes", ext.getChequeBookSizes());
|
||||
map.put("chequeMaxBooksOutstanding", ext.getChequeMaxBooksOutstanding());
|
||||
map.put("chequeReissueWhenLeaves", ext.getChequeReissueWhenLeaves());
|
||||
map.put("chequeFirstBookFree", ext.getChequeFirstBookFree());
|
||||
map.put("chequeFreeBooksPerYear", ext.getChequeFreeBooksPerYear());
|
||||
map.put("chequeNamePrinted", ext.getChequeNamePrinted());
|
||||
map.put("chequePrintDescription", ext.getChequePrintDescription());
|
||||
map.put("chequeStopPaymentAllowed", ext.getChequeStopPaymentAllowed());
|
||||
map.put("chequeMaintainStockRegister", ext.getChequeMaintainStockRegister());
|
||||
map.put("chequeAllowWithdrawalSlip", ext.getChequeAllowWithdrawalSlip());
|
||||
map.put("chequeBlockNpa", ext.getChequeBlockNpa());
|
||||
map.put("chequeBlockLimitExpired", ext.getChequeBlockLimitExpired());
|
||||
map.put("chequeBlockOverDp", ext.getChequeBlockOverDp());
|
||||
map.put("chequeBlockFrozen", ext.getChequeBlockFrozen());
|
||||
map.put("chequeBlockLienReleased", ext.getChequeBlockLienReleased());
|
||||
// Loans A/C (EMI) wizard fields
|
||||
map.put("securityMandatory", ext.getSecurityMandatory());
|
||||
map.put("guarantorsRequired", ext.getGuarantorsRequired());
|
||||
map.put("coApplicant", ext.getCoApplicant());
|
||||
map.put("thirdPartySecurity", ext.getThirdPartySecurity());
|
||||
map.put("legalOpinionRequired", ext.getLegalOpinionRequired());
|
||||
map.put("propertyInsuranceRequired", ext.getPropertyInsuranceRequired());
|
||||
map.put("registerCharge", ext.getRegisterCharge());
|
||||
map.put("releaseSecurityOnClosure", ext.getReleaseSecurityOnClosure());
|
||||
map.put("allowMultipleSecurities", ext.getAllowMultipleSecurities());
|
||||
map.put("allowThirdPartyDeposit", ext.getAllowThirdPartyDeposit());
|
||||
map.put("minAge", ext.getMinAge());
|
||||
map.put("maxAgeAtMaturity", ext.getMaxAgeAtMaturity());
|
||||
map.put("minCibil", ext.getMinCibil());
|
||||
map.put("minMonthlyIncome", ext.getMinMonthlyIncome());
|
||||
map.put("maxFoir", ext.getMaxFoir());
|
||||
map.put("maxLoansPerCustomer", ext.getMaxLoansPerCustomer());
|
||||
map.put("disbursementType", ext.getDisbursementType());
|
||||
map.put("disbursementMode", ext.getDisbursementMode());
|
||||
map.put("undisbursedInterest", ext.getUndisbursedInterest());
|
||||
map.put("preEmiInterest", ext.getPreEmiInterest());
|
||||
map.put("emiDueDay", ext.getEmiDueDay());
|
||||
map.put("holidayRule", ext.getHolidayRule());
|
||||
map.put("moratoriumMonths", ext.getMoratoriumMonths());
|
||||
map.put("moratoriumInterest", ext.getMoratoriumInterest());
|
||||
map.put("variableInstallments", ext.getVariableInstallments());
|
||||
map.put("repaymentMode", ext.getRepaymentMode());
|
||||
map.put("linkedAccountType", ext.getLinkedAccountType());
|
||||
map.put("pdcCollection", ext.getPdcCollection());
|
||||
map.put("mandateRequired", ext.getMandateRequired());
|
||||
map.put("advanceEmi", ext.getAdvanceEmi());
|
||||
map.put("rateType", ext.getRateType());
|
||||
map.put("registerParticular", ext.getRegisterParticular());
|
||||
map.put("applyCibilAdjustment", ext.getApplyCibilAdjustment());
|
||||
map.put("rateResolvedAt", ext.getRateResolvedAt());
|
||||
map.put("calcPeriod", ext.getCalcPeriod());
|
||||
map.put("interestStartsFrom", ext.getInterestStartsFrom());
|
||||
map.put("compounding", ext.getCompounding());
|
||||
map.put("benchmark", ext.getBenchmark());
|
||||
map.put("resetFrequency", ext.getResetFrequency());
|
||||
map.put("resetChange", ext.getResetChange());
|
||||
map.put("partPrepaymentAllowed", ext.getPartPrepaymentAllowed());
|
||||
map.put("minPartPayment", ext.getMinPartPayment());
|
||||
map.put("allowedOn", ext.getAllowedOn());
|
||||
map.put("partPaymentsPerYear", ext.getPartPaymentsPerYear());
|
||||
map.put("partPrepaymentMode", ext.getPartPrepaymentMode());
|
||||
map.put("foreclosureFloatingIndividual", ext.getForeclosureFloatingIndividual());
|
||||
map.put("foreclosureFixedIndividual", ext.getForeclosureFixedIndividual());
|
||||
map.put("foreclosureNonIndividual", ext.getForeclosureNonIndividual());
|
||||
map.put("foreclosureTakeover", ext.getForeclosureTakeover());
|
||||
map.put("foreclosureQuoteValidDays", ext.getForeclosureQuoteValidDays());
|
||||
map.put("autoCloseOnZeroBalance", ext.getAutoCloseOnZeroBalance());
|
||||
map.put("nocLetter", ext.getNocLetter());
|
||||
map.put("restructuring", ext.getRestructuring());
|
||||
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
|
||||
map.put("topUpAllowed", ext.getTopUpAllowed());
|
||||
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
+11
@@ -97,6 +97,8 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
|
||||
private final CreditAllocationsJsonParser creditAllocationsJsonParser;
|
||||
private final LoanProductAssembler loanProductAssembler;
|
||||
private final LoanProductUpdateUtil loanProductUpdateUtil;
|
||||
// "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book) that m_product_loan has no home for.
|
||||
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
|
||||
private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger();
|
||||
private final LoanProductCreditAllocationRuleMerger loanProductCreditAllocationRuleMerger = new LoanProductCreditAllocationRuleMerger();
|
||||
|
||||
@@ -141,6 +143,10 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
|
||||
|
||||
this.loanProductRepository.saveAndFlush(loanProduct);
|
||||
|
||||
// "Advances A/C" satellite fields, keyed on the product's generated id. A standard product carries
|
||||
// none of these parameters and writes no row. Same transaction: a failure rolls the product back.
|
||||
this.loanProductAdvanceExtService.saveOrUpdate(loanProduct, command);
|
||||
|
||||
// save accounting mappings
|
||||
this.accountMappingWritePlatformService.createLoanProductToGLAccountMapping(loanProduct.getId(), command);
|
||||
// check if the office specific products are enabled. If yes, then
|
||||
@@ -303,6 +309,11 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
|
||||
this.loanProductRepository.saveAndFlush(product);
|
||||
}
|
||||
|
||||
// "Advances A/C" satellite fields. Outside the changes check above (those track only the standard
|
||||
// product columns) so an edit that touched only an advance field is still saved, and its per-field
|
||||
// diff is folded into the same audit changes map.
|
||||
changes.putAll(this.loanProductAdvanceExtService.saveOrUpdate(product, command));
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(loanProductId) //
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
|
||||
|
||||
public interface LoanRateRegisterReadPlatformService {
|
||||
|
||||
/** All versions, newest-effective first — the Version History accordion feeds off this. */
|
||||
List<LoanRateRegisterData> retrieveAll();
|
||||
|
||||
/** The current active version, assembled with its chart cells + CIBIL bands. */
|
||||
Optional<LoanRateRegisterData> retrieveActive();
|
||||
|
||||
/** A specific version by id. */
|
||||
Optional<LoanRateRegisterData> retrieveById(Long id);
|
||||
|
||||
/** Master list of particulars (loan types) that make up the chart rows, in chart order. */
|
||||
List<LoanRateRegisterProductData> retrieveProducts();
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterCibilSlabData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterSlabData;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Assembles {@link LoanRateRegisterData} responses by joining the three physical tables. Kept
|
||||
* read-only — all writes go through {@link LoanRateRegisterWritePlatformService}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegisterReadPlatformService {
|
||||
|
||||
private final LoanRateRegisterEffectiveRepository effectiveRepository;
|
||||
private final LoanRateRegisterSlabHistoryRepository slabRepository;
|
||||
private final LoanRateRegisterCibilSlabRepository cibilRepository;
|
||||
private final LoanRateRegisterProductRepository productRepository;
|
||||
|
||||
@Override
|
||||
public List<LoanRateRegisterProductData> retrieveProducts() {
|
||||
final List<LoanRateRegisterProduct> products = this.productRepository.findByIsActiveTrueOrderBySrNoAsc();
|
||||
final List<LoanRateRegisterProductData> out = new ArrayList<>(products.size());
|
||||
for (final LoanRateRegisterProduct p : products) {
|
||||
out.add(LoanRateRegisterProductData.builder()
|
||||
.id(p.getId())
|
||||
.srNo(p.getSrNo())
|
||||
.name(p.getName())
|
||||
.glAccountId(p.getGlAccountId())
|
||||
.isActive(p.getIsActive())
|
||||
.build());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LoanRateRegisterData> retrieveAll() {
|
||||
final List<LoanRateRegisterEffective> versions = this.effectiveRepository.findAllByOrderByEffectiveFromDateDesc();
|
||||
final List<LoanRateRegisterData> out = new ArrayList<>(versions.size());
|
||||
for (final LoanRateRegisterEffective v : versions) {
|
||||
out.add(assemble(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<LoanRateRegisterData> retrieveActive() {
|
||||
return this.effectiveRepository.findFirstByIsActiveTrueOrderByEffectiveFromDateDesc().map(this::assemble);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<LoanRateRegisterData> retrieveById(final Long id) {
|
||||
return this.effectiveRepository.findById(id).map(this::assemble);
|
||||
}
|
||||
|
||||
/** Build the full response for one effective version — header + its chart cells + its CIBIL bands. */
|
||||
private LoanRateRegisterData assemble(final LoanRateRegisterEffective v) {
|
||||
final List<LoanRateRegisterSlabHistory> cells = this.slabRepository
|
||||
.findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(v.getId());
|
||||
final List<LoanRateRegisterCibilSlab> bands = this.cibilRepository
|
||||
.findByEffectiveIdOrderByDisplayOrderAsc(v.getId());
|
||||
|
||||
final List<LoanRateRegisterSlabData> slabData = new ArrayList<>(cells.size());
|
||||
for (final LoanRateRegisterSlabHistory c : cells) {
|
||||
slabData.add(LoanRateRegisterSlabData.builder()
|
||||
.id(c.getId())
|
||||
.productId(c.getProductId())
|
||||
.productSrNo(c.getProductSrNo())
|
||||
.productName(c.getProductName())
|
||||
.slabOrder(c.getSlabOrder())
|
||||
.slabLabel(c.getSlabLabel())
|
||||
.amountUpto(c.getAmountUpto())
|
||||
.rateValue(c.getRateValue())
|
||||
.rateFormula(c.getRateFormula())
|
||||
.isFormula(c.getIsFormula())
|
||||
.isNa(c.getIsNa())
|
||||
.build());
|
||||
}
|
||||
|
||||
final List<LoanRateRegisterCibilSlabData> cibilData = new ArrayList<>(bands.size());
|
||||
for (final LoanRateRegisterCibilSlab b : bands) {
|
||||
cibilData.add(LoanRateRegisterCibilSlabData.builder()
|
||||
.id(b.getId())
|
||||
.scoreFrom(b.getScoreFrom())
|
||||
.scoreTo(b.getScoreTo())
|
||||
.tier(b.getTier())
|
||||
.slabType(b.getSlabType())
|
||||
.adjustmentPct(b.getAdjustmentPct())
|
||||
.displayOrder(b.getDisplayOrder())
|
||||
.build());
|
||||
}
|
||||
|
||||
return LoanRateRegisterData.builder()
|
||||
.id(v.getId())
|
||||
.name(v.getName())
|
||||
.effectiveFromDate(v.getEffectiveFromDate())
|
||||
.effectiveToDate(v.getEffectiveToDate())
|
||||
.currencyCode(v.getCurrencyCode())
|
||||
.isActive(v.getIsActive())
|
||||
.fallbackNoCibilSlab(v.getFallbackNoCibilSlab())
|
||||
.fallbackBelowMin(v.getFallbackBelowMin())
|
||||
.note(v.getNote())
|
||||
.createdById(v.getCreatedById())
|
||||
.createdDate(v.getCreatedDate())
|
||||
.slabs(slabData)
|
||||
.cibilSlabs(cibilData)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface LoanRateRegisterWritePlatformService {
|
||||
|
||||
/** Save a new version of the loan interest rate register (maker-checker gated upstream). */
|
||||
CommandProcessingResult saveRateRegister(JsonCommand command);
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.ProductRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.serialization.LoanRateRegisterDataValidator;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Persists a new effective version of the Loan Interest Rate Register. Called from the command
|
||||
* handler, which Fineract routes to only AFTER a checker has approved the maker's submission. The
|
||||
* whole save runs inside one transaction: previous active deactivated → new effective inserted →
|
||||
* all chart cells + CIBIL bands inserted. Any failure rolls everything back.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegisterWritePlatformService {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final LoanRateRegisterDataValidator validator;
|
||||
private final LoanRateRegisterEffectiveRepository effectiveRepository;
|
||||
private final LoanRateRegisterSlabHistoryRepository slabRepository;
|
||||
private final LoanRateRegisterCibilSlabRepository cibilRepository;
|
||||
private final LoanRateRegisterProductRepository productRepository;
|
||||
private final GLAccountRepositoryWrapper glAccountRepositoryWrapper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult saveRateRegister(final JsonCommand command) {
|
||||
final LoanRateRegisterSaveRequest req = gson.fromJson(command.json(), LoanRateRegisterSaveRequest.class);
|
||||
this.validator.validate(req);
|
||||
|
||||
final Long createdById = this.context.authenticatedUser().getId();
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate effectiveFrom = parseDateOrToday(req.getEffectiveFromDate(), today);
|
||||
|
||||
// 0) Upsert the particulars master first, so a missing GL on a new product fails the whole
|
||||
// save before any version row is touched. Returns srNo -> master row for the cells below.
|
||||
final Map<Integer, LoanRateRegisterProduct> productsBySrNo = upsertProducts(req);
|
||||
|
||||
// 1) Deactivate the currently-active version (if any).
|
||||
final List<LoanRateRegisterEffective> active = this.effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc();
|
||||
for (final LoanRateRegisterEffective prior : active) {
|
||||
prior.deactivate(today);
|
||||
}
|
||||
if (!active.isEmpty()) {
|
||||
this.effectiveRepository.saveAll(active);
|
||||
}
|
||||
|
||||
// 2) Insert the new effective row. saveAndFlush ensures Hibernate executes the INSERT
|
||||
// immediately so the auto-generated id is populated before we use it as the FK on the
|
||||
// child slab/CIBIL rows (same pattern as FD side's FdBaseInterestRateReadPlatformServiceImpl).
|
||||
final LoanRateRegisterEffective fresh = LoanRateRegisterEffective.create(req.getName(), effectiveFrom,
|
||||
req.getCurrencyCode(), req.getFallbackNoCibilSlab(), req.getFallbackBelowMin(), req.getNote(), createdById);
|
||||
final LoanRateRegisterEffective saved = this.effectiveRepository.saveAndFlush(fresh);
|
||||
final Long versionId = saved.getId();
|
||||
|
||||
// 3) Insert all chart cells for this version.
|
||||
final List<LoanRateRegisterSlabHistory> cells = new ArrayList<>(req.getSlabs().size());
|
||||
for (final SlabRequest s : req.getSlabs()) {
|
||||
final Long productId = productsBySrNo.get(s.getProductSrNo()).getId();
|
||||
cells.add(LoanRateRegisterSlabHistory.create(versionId, productId, s.getProductSrNo(), s.getProductName(),
|
||||
s.getSlabOrder(), s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(),
|
||||
Boolean.TRUE.equals(s.getIsFormula()), Boolean.TRUE.equals(s.getIsNa())));
|
||||
}
|
||||
this.slabRepository.saveAll(cells);
|
||||
|
||||
// 4) Insert all CIBIL bands for this version.
|
||||
final List<LoanRateRegisterCibilSlab> bands = new ArrayList<>(req.getCibilSlabs().size());
|
||||
for (final CibilSlabRequest c : req.getCibilSlabs()) {
|
||||
bands.add(LoanRateRegisterCibilSlab.create(versionId, c.getScoreFrom(), c.getScoreTo(), c.getTier(),
|
||||
c.getSlabType(), c.getAdjustmentPct(), c.getDisplayOrder()));
|
||||
}
|
||||
this.cibilRepository.saveAll(bands);
|
||||
|
||||
return new CommandProcessingResultBuilder()
|
||||
.withCommandId(command.commandId())
|
||||
.withEntityId(versionId)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure every particular used by the submitted chart exists in the master. A particular not
|
||||
* yet in the master is created and must carry a valid GL account; for an existing one a changed
|
||||
* GL or name is applied. A particular that still has no GL after this (seeded rows the bank has
|
||||
* not mapped yet) rejects the whole save.
|
||||
*/
|
||||
private Map<Integer, LoanRateRegisterProduct> upsertProducts(final LoanRateRegisterSaveRequest req) {
|
||||
final Map<Integer, LoanRateRegisterProduct> bySrNo = new HashMap<>();
|
||||
for (final LoanRateRegisterProduct p : this.productRepository.findAll()) {
|
||||
bySrNo.put(p.getSrNo(), p);
|
||||
}
|
||||
final Map<Integer, ProductRequest> requested = new HashMap<>();
|
||||
if (req.getProducts() != null) {
|
||||
for (final ProductRequest pr : req.getProducts()) {
|
||||
requested.put(pr.getSrNo(), pr);
|
||||
}
|
||||
}
|
||||
// Every srNo the chart uses, with a label taken from the first cell that mentions it.
|
||||
final Set<Integer> used = new LinkedHashSet<>();
|
||||
final Map<Integer, String> cellLabel = new HashMap<>();
|
||||
for (final SlabRequest s : req.getSlabs()) {
|
||||
used.add(s.getProductSrNo());
|
||||
cellLabel.putIfAbsent(s.getProductSrNo(), s.getProductName());
|
||||
}
|
||||
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final List<LoanRateRegisterProduct> toSave = new ArrayList<>();
|
||||
for (final Integer srNo : used) {
|
||||
final ProductRequest pr = requested.get(srNo);
|
||||
LoanRateRegisterProduct existing = bySrNo.get(srNo);
|
||||
final String name = pr != null && pr.getName() != null && !pr.getName().isBlank() ? pr.getName() : cellLabel.get(srNo);
|
||||
if (existing == null) {
|
||||
if (pr == null || pr.getGlAccountId() == null) {
|
||||
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
|
||||
"New product '" + name + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
|
||||
continue;
|
||||
}
|
||||
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
|
||||
existing = new LoanRateRegisterProduct();
|
||||
existing.setSrNo(srNo);
|
||||
existing.setName(name);
|
||||
existing.setGlAccountId(pr.getGlAccountId());
|
||||
existing.setIsActive(true);
|
||||
existing.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
toSave.add(existing);
|
||||
} else if (pr != null) {
|
||||
boolean changed = false;
|
||||
if (pr.getGlAccountId() != null && !pr.getGlAccountId().equals(existing.getGlAccountId())) {
|
||||
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
|
||||
existing.setGlAccountId(pr.getGlAccountId());
|
||||
changed = true;
|
||||
}
|
||||
if (name != null && !name.equals(existing.getName())) {
|
||||
existing.setName(name);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
toSave.add(existing);
|
||||
}
|
||||
}
|
||||
// Every particular in a submitted chart must end up with a GL, including the seeded ones.
|
||||
if (existing.getGlAccountId() == null) {
|
||||
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
|
||||
"Product '" + existing.getName() + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
|
||||
continue;
|
||||
}
|
||||
bySrNo.put(srNo, existing);
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.loanRateRegister.failed", "Validation errors exist.", errors);
|
||||
}
|
||||
for (final LoanRateRegisterProduct p : this.productRepository.saveAllAndFlush(toSave)) {
|
||||
bySrNo.put(p.getSrNo(), p);
|
||||
}
|
||||
return bySrNo;
|
||||
}
|
||||
|
||||
private static LocalDate parseDateOrToday(final String iso, final LocalDate today) {
|
||||
if (iso == null || iso.isBlank()) {
|
||||
return today;
|
||||
}
|
||||
return LocalDate.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.portfolio.rate.domain.RateRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.rate.service.RateReadService;
|
||||
@@ -82,11 +83,12 @@ public class LoanProductConfiguration {
|
||||
DelinquencyBucketRepository delinquencyBucketRepository,
|
||||
LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory,
|
||||
AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser, CreditAllocationsJsonParser creditAllocationsJsonParser,
|
||||
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil) {
|
||||
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil,
|
||||
LoanProductAdvanceExtService loanProductAdvanceExtService) {
|
||||
return new LoanProductWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, loanProductRepository, aprCalculator,
|
||||
fundRepository, chargeRepository, rateRepository, accountMappingWritePlatformService, fineractEntityAccessUtil,
|
||||
floatingRateRepository, loanRepositoryWrapper, businessEventNotifierService, delinquencyBucketRepository,
|
||||
loanRepaymentScheduleTransactionProcessorFactory, advancedPaymentJsonParser, creditAllocationsJsonParser,
|
||||
loanProductAssembler, loanProductUpdateUtil);
|
||||
loanProductAssembler, loanProductUpdateUtil, loanProductAdvanceExtService);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -210,12 +210,14 @@ public class RbiReserveConfigApiResource {
|
||||
ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter");
|
||||
} else {
|
||||
ROLE_GROUPS.put(role, "RESERVE");
|
||||
// Both ratios are settled on Form 1 - Part B is section 18, Part C section 24 - so
|
||||
// the part is named rather than a separate return that does not exist.
|
||||
if ("SLR_INVESTMENTS".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 1 Part C (SLR) — maintained");
|
||||
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
|
||||
ROLE_USAGE.put(role, "Form 1 — base for both ratios");
|
||||
} else {
|
||||
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+260
-55
@@ -1084,6 +1084,15 @@ public class RbiReserveReturnApiResource {
|
||||
*/
|
||||
private static LocalDate requirePastOrPresent(final LocalDate date, final String what) {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
// A year the ledger could not hold. A date field hands over whatever has been typed so far,
|
||||
// so a half-typed year arrives as a real date - "0026-09-30" parses cleanly - and the return
|
||||
// then reads a period no bank has entries for and reports every line nil.
|
||||
if (date.getYear() < 1900) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.year.incomplete",
|
||||
what + " (" + date + ") has a year below 1900, which looks like a date still being "
|
||||
+ "typed rather than a period to report.",
|
||||
what, date.toString());
|
||||
}
|
||||
if (date.isAfter(today)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.future.date",
|
||||
what + " (" + date + ") is in the future; the bank's business date is " + today
|
||||
@@ -1130,6 +1139,8 @@ public class RbiReserveReturnApiResource {
|
||||
public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam,
|
||||
@QueryParam("firstColumnDate") final String firstColumnDateParam,
|
||||
@QueryParam("secondColumnDate") final String secondColumnDateParam,
|
||||
@QueryParam("firstBaseDate") final String firstBaseDateParam,
|
||||
@QueryParam("secondBaseDate") final String secondBaseDateParam,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
@@ -1174,15 +1185,51 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
// The third column is the base the current fortnight's ratios are charged on - the last
|
||||
// Friday of the second preceding fortnight - not the reporting date.
|
||||
// matching close one month earlier - not the reporting date.
|
||||
//
|
||||
// It was the reporting date. Since the return is now made up to the second column, that
|
||||
// made the third column a copy of the second: two identically dated columns carrying
|
||||
// identical figures, which reads as a filled form and says nothing. The base belongs on the
|
||||
// face of the form because every required amount below is computed from it, and a reviewer
|
||||
// cannot check a requirement whose base is not stated.
|
||||
final LocalDate currentBase = formOneBaseDate(currentClose);
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose, currentBase);
|
||||
// Each close has its own base, and either can be given outright.
|
||||
//
|
||||
// Derived, the base is the matching close one month earlier, which is
|
||||
// what the rules fix it at. But a bank migrating onto the system, or filing a corrected
|
||||
// return for a period whose base fell before its ledger starts, has a base date that the
|
||||
// rule produces and the ledger cannot answer - and the ratio then reads nil against real
|
||||
// liabilities. Given explicitly, the date entered is used and stated on the form, so a
|
||||
// reader can see which base each column was charged on.
|
||||
final LocalDate previousBase = firstBaseDateParam == null || firstBaseDateParam.isBlank()
|
||||
? formOneBaseDate(previousClose)
|
||||
: requirePastOrPresent(LocalDate.parse(firstBaseDateParam), "The first column's base date");
|
||||
final LocalDate currentBase = secondBaseDateParam == null || secondBaseDateParam.isBlank()
|
||||
? formOneBaseDate(currentClose)
|
||||
: requirePastOrPresent(LocalDate.parse(secondBaseDateParam), "The second column's base date");
|
||||
|
||||
// A base dated after the close it serves would charge the ratio on liabilities the bank had
|
||||
// not yet taken on - the figure would be arithmetically fine and mean nothing.
|
||||
if (previousBase.isAfter(previousClose)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close",
|
||||
"The first column's base date (" + previousBase + ") falls after the close it is charged on ("
|
||||
+ previousClose + ").",
|
||||
previousBase.toString(), previousClose.toString());
|
||||
}
|
||||
if (currentBase.isAfter(currentClose)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close",
|
||||
"The second column's base date (" + currentBase + ") falls after the close it is charged on ("
|
||||
+ currentClose + ").",
|
||||
currentBase.toString(), currentClose.toString());
|
||||
}
|
||||
|
||||
// Two columns, one per fortnight close. The base dates are not columns of their own: they
|
||||
// are stated against the lines they settle, and a third column headed with one printed the
|
||||
// base's own balances beside the closes', which read as a third period the bank was
|
||||
// reporting rather than as the figures the ratios were charged on.
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose);
|
||||
// One base per column, in the same order, so each column is charged on its own base rather
|
||||
// than both on one.
|
||||
final List<LocalDate> baseDates = List.of(previousBase, currentBase);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("formCode", "FORM_1_STATUTORY");
|
||||
@@ -1190,6 +1237,9 @@ public class RbiReserveReturnApiResource {
|
||||
response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966");
|
||||
response.put("reportingDate", reportingDate.toString());
|
||||
response.put("columns", columnDates.stream().map(LocalDate::toString).toList());
|
||||
// Stated beside the columns so a reader can see which date each ratio was charged on,
|
||||
// without the base needing a column of its own.
|
||||
response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList());
|
||||
|
||||
final Rate crr = rateInForce("CRR", reportingDate);
|
||||
final Rate slr = rateInForce("SLR", reportingDate);
|
||||
@@ -1197,10 +1247,14 @@ public class RbiReserveReturnApiResource {
|
||||
response.put("slrPercentage", slr == null ? null : slr.percentage);
|
||||
|
||||
final List<Map<String, Object>> parts = new ArrayList<>();
|
||||
parts.add(formOnePartA(columnDates, officeId, crr, slr));
|
||||
parts.add(formOnePartA(columnDates, baseDates, officeId, crr, slr));
|
||||
response.put("partA", parts.get(0));
|
||||
response.put("partB", formOnePartB(columnDates, officeId, crr));
|
||||
response.put("partC", formOnePartC(columnDates, officeId, slr));
|
||||
response.put("partB", formOnePartB(columnDates, baseDates, officeId, crr));
|
||||
response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr));
|
||||
|
||||
// Every day of the month the closes fall in, charged on each day's own base. The form
|
||||
// reports two closes; compliance turns on every day between them.
|
||||
response.put("daily", formOneDaily(previousClose.withDayOfMonth(1), currentClose, officeId, crr, slr));
|
||||
|
||||
final List<String> unmet = new ArrayList<>();
|
||||
if (crr == null) {
|
||||
@@ -1219,9 +1273,8 @@ public class RbiReserveReturnApiResource {
|
||||
final BigDecimal baseLiabilities = formOneNetLiabilities(currentBase, officeId);
|
||||
if (baseLiabilities.compareTo(BigDecimal.ZERO) == 0) {
|
||||
unmet.add("Both ratios are charged on the net liabilities of " + currentBase
|
||||
+ " — the last Friday of the second preceding fortnight — and the ledger holds "
|
||||
+ "none on that date. The required amounts are therefore nil. They are nil "
|
||||
+ "because the base is nil, not because nothing is required.");
|
||||
+ ", and the ledger holds none on that date. The required amounts are therefore "
|
||||
+ "nil — nil because the base is nil, not because nothing is required.");
|
||||
}
|
||||
|
||||
response.put("unmetRequirements", unmet);
|
||||
@@ -1229,7 +1282,7 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
/** Part A: the liabilities and assets the two ratios are worked out from. */
|
||||
private Map<String, Object> formOnePartA(final List<LocalDate> dates, final Long officeId, final Rate crr,
|
||||
private Map<String, Object> formOnePartA(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate crr,
|
||||
final Rate slr) {
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
|
||||
@@ -1279,7 +1332,25 @@ public class RbiReserveReturnApiResource {
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
iv.add(totalI.get(i).subtract(totalIII.get(i)).max(BigDecimal.ZERO).add(totalII.get(i)));
|
||||
}
|
||||
lines.add(formOneTotal("IV. TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24", iv));
|
||||
// The form carries IV twice, and the difference between the two is the whole of how the
|
||||
// ratios work. (a) is this column's own liabilities - what the bank owed on the close being
|
||||
// reported. (b) is the base: the same figure read at the earlier close, and the only one the
|
||||
// requirement is actually charged on. A form showing one IV reads as though the bank had to
|
||||
// hold a ratio of today's liabilities, which is not what either section says.
|
||||
lines.add(formOneTotal(
|
||||
"IV. a) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [CURRENT FORTNIGHT]", iv));
|
||||
final List<BigDecimal> ivBase = new ArrayList<>();
|
||||
final List<String> ivBaseDates = new ArrayList<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
final LocalDate base = baseFor(bases, dates, i);
|
||||
ivBaseDates.add(base.toString());
|
||||
ivBase.add(formOneNetLiabilities(base, officeId));
|
||||
}
|
||||
final Map<String, Object> ivBaseRow = formOneTotal(
|
||||
"IV. b) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [SECOND PRECEDING FORTNIGHT]",
|
||||
ivBase);
|
||||
ivBaseRow.put("baseDates", ivBaseDates);
|
||||
lines.add(ivBaseRow);
|
||||
|
||||
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0);
|
||||
|
||||
@@ -1311,37 +1382,25 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii));
|
||||
|
||||
// The liabilities the ratios are charged on: the last Friday of the second preceding
|
||||
// fortnight, read per column rather than once, so each column states its own base.
|
||||
final List<BigDecimal> baseDateDtl = new ArrayList<>();
|
||||
final List<String> baseDates = new ArrayList<>();
|
||||
for (final LocalDate date : dates) {
|
||||
final LocalDate base = formOneBaseDate(date);
|
||||
baseDates.add(base.toString());
|
||||
baseDateDtl.add(formOneNetLiabilities(base, officeId));
|
||||
}
|
||||
final Map<String, Object> baseRow = formOneTotal(
|
||||
"DEMAND AND TIME LIABILITIES OF THE BASE DATE — last Friday of the second preceding fortnight",
|
||||
baseDateDtl);
|
||||
baseRow.put("baseDates", baseDates);
|
||||
lines.add(baseRow);
|
||||
|
||||
// The base liabilities are IV(b) above - it used to be repeated as a row of its own at the
|
||||
// foot of the part, which put the same figure on the form twice under two different names.
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("title", "PART A");
|
||||
part.put("lines", lines);
|
||||
part.put("baseDates", baseDates);
|
||||
part.put("baseDateLiabilities", baseDateDtl);
|
||||
part.put("baseDates", ivBaseDates);
|
||||
part.put("baseDateLiabilities", ivBase);
|
||||
return part;
|
||||
}
|
||||
|
||||
/** Part B: compliance with section 18 — the cash reserve. */
|
||||
private Map<String, Object> formOnePartB(final List<LocalDate> dates, final Long officeId, final Rate crr) {
|
||||
private Map<String, Object> formOnePartB(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate crr) {
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
final List<BigDecimal> required = new ArrayList<>();
|
||||
final List<BigDecimal> maintained = new ArrayList<>();
|
||||
|
||||
for (final LocalDate date : dates) {
|
||||
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId);
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
final LocalDate date = dates.get(i);
|
||||
final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId);
|
||||
required.add(crr == null ? BigDecimal.ZERO
|
||||
: base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP));
|
||||
// X = V + VI + VIII, the form's own definition.
|
||||
@@ -1354,8 +1413,12 @@ public class RbiReserveReturnApiResource {
|
||||
maintained.add(cash.add(currentAccounts).add(netCurrent));
|
||||
}
|
||||
|
||||
lines.add(formOneTotal("IX. " + (crr == null ? "CRR" : crr.percentage + "%")
|
||||
+ " of IV as on the last Friday of the second preceding fortnight", required));
|
||||
// The rate is named against IV(b), not IV, because that is the figure it is charged on. The
|
||||
// template's own wording says "Last Friday of the Second Preceding fortnight"; this bank's
|
||||
// base rule is the matching close of the previous month, so the line says IV(b) and leaves
|
||||
// the base date itself to the column heading rather than claiming a Friday it did not use.
|
||||
lines.add(formOneTotal("IX. CRR REQUIRED TO BE MAINTAINED — " + (crr == null ? "rate not set" : crr.percentage + "% of IV(b)"),
|
||||
required));
|
||||
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained));
|
||||
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
@@ -1368,15 +1431,16 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
/** Part C: compliance with section 24 — the liquid assets. */
|
||||
private Map<String, Object> formOnePartC(final List<LocalDate> dates, final Long officeId, final Rate slr) {
|
||||
private Map<String, Object> formOnePartC(final List<LocalDate> dates, final List<LocalDate> bases, final Long officeId, final Rate slr) {
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
final List<BigDecimal> required = new ArrayList<>();
|
||||
final List<BigDecimal> cashAndOther = new ArrayList<>();
|
||||
final List<BigDecimal> gold = new ArrayList<>();
|
||||
final List<BigDecimal> securities = new ArrayList<>();
|
||||
|
||||
for (final LocalDate date : dates) {
|
||||
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId);
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
final LocalDate date = dates.get(i);
|
||||
final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId);
|
||||
final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
|
||||
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
required.add(slrRequired);
|
||||
@@ -1395,8 +1459,9 @@ public class RbiReserveReturnApiResource {
|
||||
securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount);
|
||||
}
|
||||
|
||||
lines.add(formOneTotal("XI. " + (slr == null ? "SLR" : slr.percentage + "%")
|
||||
+ " of IV as on the last Friday of the second preceding fortnight", required));
|
||||
// Charged on IV(b) like the cash reserve above, and named the same way.
|
||||
lines.add(formOneTotal("XI. SLR REQUIRED TO BE MAINTAINED — " + (slr == null ? "rate not set" : slr.percentage + "% of IV(b)"),
|
||||
required));
|
||||
lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED"));
|
||||
lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1));
|
||||
lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1));
|
||||
@@ -1413,6 +1478,115 @@ public class RbiReserveReturnApiResource {
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day of the period the form covers, with what each ratio required that day and what was
|
||||
* held against it.
|
||||
*
|
||||
* <p>
|
||||
* The form itself reports two closes, and a bank that held nothing for thirteen days and topped
|
||||
* up on the fourteenth has a form that reads as compliant. Both ratios are reckoned over the
|
||||
* whole period, so the day-by-day position is what the compliance actually turns on - and it is
|
||||
* the figure an inspector asks for first.
|
||||
*
|
||||
* <p>
|
||||
* Each day is charged on ITS OWN base, read through the same rule the columns use, so a day in
|
||||
* the first fortnight is measured against a different requirement from one in the second. A
|
||||
* single required amount applied across the month would show every day of one fortnight passing
|
||||
* or failing for the wrong reason.
|
||||
*
|
||||
* @param from the first day reported, usually the start of the month the closes fall in.
|
||||
* @param to the last, which is the later close.
|
||||
*/
|
||||
private Map<String, Object> formOneDaily(final LocalDate from, final LocalDate to, final Long officeId,
|
||||
final Rate crr, final Rate slr) {
|
||||
final List<Map<String, Object>> days = new ArrayList<>();
|
||||
BigDecimal crrTotal = BigDecimal.ZERO;
|
||||
BigDecimal slrTotal = BigDecimal.ZERO;
|
||||
BigDecimal crrLowest = null;
|
||||
BigDecimal slrLowest = null;
|
||||
LocalDate crrLowestOn = null;
|
||||
LocalDate slrLowestOn = null;
|
||||
int crrShortDays = 0;
|
||||
int slrShortDays = 0;
|
||||
|
||||
for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) {
|
||||
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(day), officeId);
|
||||
final BigDecimal crrRequired = crr == null ? BigDecimal.ZERO
|
||||
: base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
|
||||
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
|
||||
final BigDecimal crrHeld = formOnePartBMaintained(day, officeId);
|
||||
// SLR counts the cash held OVER the section 18 requirement, plus the other balances and
|
||||
// the securities - the same definition Part C uses, so the two cannot disagree.
|
||||
final BigDecimal excessCash = crrHeld.subtract(crrRequired).max(BigDecimal.ZERO);
|
||||
final BigDecimal slrHeld = excessCash
|
||||
.add(figureFor("FORM1_VII_A_STATE_COOP_OTHER", day, officeId).amount)
|
||||
.add(figureFor("FORM1_VII_B_DCCB_OTHER", day, officeId).amount)
|
||||
.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", day, officeId).amount)
|
||||
.add(figureFor("FORMA_SLR_GOLD", day, officeId).amount)
|
||||
.add(figureFor("SLR_INVESTMENTS", day, officeId).amount);
|
||||
|
||||
final BigDecimal crrSurplus = crrHeld.subtract(crrRequired);
|
||||
final BigDecimal slrSurplus = slrHeld.subtract(slrRequired);
|
||||
if (crrSurplus.compareTo(BigDecimal.ZERO) < 0) {
|
||||
crrShortDays++;
|
||||
}
|
||||
if (slrSurplus.compareTo(BigDecimal.ZERO) < 0) {
|
||||
slrShortDays++;
|
||||
}
|
||||
|
||||
crrTotal = crrTotal.add(crrHeld);
|
||||
slrTotal = slrTotal.add(slrHeld);
|
||||
if (crrLowest == null || crrHeld.compareTo(crrLowest) < 0) {
|
||||
crrLowest = crrHeld;
|
||||
crrLowestOn = day;
|
||||
}
|
||||
if (slrLowest == null || slrHeld.compareTo(slrLowest) < 0) {
|
||||
slrLowest = slrHeld;
|
||||
slrLowestOn = day;
|
||||
}
|
||||
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("date", day.toString());
|
||||
row.put("baseDate", formOneBaseDate(day).toString());
|
||||
// The liabilities the day was charged on, stated rather than left to be divided back out
|
||||
// of the requirement: the CIMS daily sheets carry it as a column of its own, and on a day
|
||||
// whose rate is unset the requirement is zero, which no division recovers a base from.
|
||||
row.put("baseLiabilities", base);
|
||||
// The day's OWN liabilities, which the base above does not show. The base holds still
|
||||
// for a whole fortnight, so a table carrying only it cannot say whether the bank's
|
||||
// liabilities rose or fell on any particular day - every row of the fortnight reads the
|
||||
// same. This moves daily, and the gap between the two is the lag the rules build in.
|
||||
row.put("ownLiabilities", formOneNetLiabilities(day, officeId));
|
||||
row.put("crrRequired", crrRequired);
|
||||
row.put("crrMaintained", crrHeld);
|
||||
row.put("crrSurplus", crrSurplus);
|
||||
row.put("slrRequired", slrRequired);
|
||||
row.put("slrMaintained", slrHeld);
|
||||
row.put("slrSurplus", slrSurplus);
|
||||
days.add(row);
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("from", from.toString());
|
||||
out.put("to", to.toString());
|
||||
out.put("days", days);
|
||||
out.put("dayCount", days.size());
|
||||
final BigDecimal count = BigDecimal.valueOf(Math.max(days.size(), 1));
|
||||
out.put("crrAverage", crrTotal.divide(count, 2, RoundingMode.HALF_UP));
|
||||
out.put("slrAverage", slrTotal.divide(count, 2, RoundingMode.HALF_UP));
|
||||
out.put("crrLowest", crrLowest == null ? BigDecimal.ZERO : crrLowest);
|
||||
out.put("crrLowestOn", crrLowestOn == null ? null : crrLowestOn.toString());
|
||||
out.put("slrLowest", slrLowest == null ? BigDecimal.ZERO : slrLowest);
|
||||
out.put("slrLowestOn", slrLowestOn == null ? null : slrLowestOn.toString());
|
||||
// Counted rather than left for a reader to spot: a single day short is a breach, and on a
|
||||
// table of thirty rows it is exactly what the eye skips.
|
||||
out.put("crrShortDays", crrShortDays);
|
||||
out.put("slrShortDays", slrShortDays);
|
||||
return out;
|
||||
}
|
||||
|
||||
private BigDecimal formOnePartBRequired(final LocalDate date, final Long officeId) {
|
||||
final Rate crr = rateInForce("CRR", date);
|
||||
if (crr == null) {
|
||||
@@ -1433,23 +1607,47 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
/**
|
||||
* The date both ratios are charged on: the last Friday of the SECOND PRECEDING fortnight.
|
||||
* The base date a column's ratios are charged on.
|
||||
*
|
||||
* <p>
|
||||
* Counted in fortnights, not in weeks. For a column closing 15 August the fortnight it belongs
|
||||
* to is 1-15 August, the first preceding one is 16-31 July, and the second is 1-15 July - so
|
||||
* the base falls in the first half of July, roughly six weeks back rather than the four a
|
||||
* straight subtraction would give. Reading the rate off a date a fortnight too late would
|
||||
* report a requirement the bank never had time to meet, which is the whole point of the lag.
|
||||
* Taken from the list the caller supplied, so a base entered by hand reaches every part of the
|
||||
* form rather than only the column heading. Where no list was given - or it is shorter than the
|
||||
* columns - the statutory rule applies.
|
||||
*/
|
||||
private LocalDate baseFor(final List<LocalDate> bases, final List<LocalDate> dates, final int index) {
|
||||
if (bases != null && index < bases.size() && bases.get(index) != null) {
|
||||
return bases.get(index);
|
||||
}
|
||||
return formOneBaseDate(dates.get(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* The date a column's ratios are charged on: the previous month's matching fortnight close.
|
||||
*
|
||||
* <p>
|
||||
* Within that fortnight the form takes its last Friday, shifted off a holiday the same way
|
||||
* every other reporting date is.
|
||||
* Which fortnight the column falls in settles it, not the day it carries. The 1st to the 15th
|
||||
* charge on the 15th of the previous month; anything later charges on the previous month's END,
|
||||
* whatever day that is. So 30 September charges on 31 August - and so does 20 September, since
|
||||
* both sit in the second fortnight and the second fortnight closes with the month.
|
||||
*
|
||||
* <p>
|
||||
* Keyed on the fortnight rather than on the day number because the two closes are not the same
|
||||
* kind of date: the 15th is fixed, the month end moves between 28 and 31. Matching day for day
|
||||
* would charge 30 September on 30 August, a date that closes nothing.
|
||||
*
|
||||
* <p>
|
||||
* RBI's own wording is "the last Friday of the second preceding fortnight", and that rule has
|
||||
* been tried here twice. Both times it was taken out again for the same reason: it puts the base
|
||||
* on a weekday that moves and roughly six weeks back, and the dates it produces do not match the
|
||||
* returns this bank has already filed by hand. The fortnight closes this bank files on are the
|
||||
* 15th and the month end. Where a particular return needs some other base, it can be entered by
|
||||
* hand through {@code baseFor} above, which is why that path exists.
|
||||
*/
|
||||
private LocalDate formOneBaseDate(final LocalDate columnDate) {
|
||||
final LocalDate ownClose = fortnightCloseOnOrAfter(columnDate);
|
||||
final LocalDate secondPrecedingClose = fortnightCloseBefore(fortnightCloseBefore(ownClose));
|
||||
return precedingWorkingDay(reportingFridayOnOrBefore(secondPrecedingClose));
|
||||
final LocalDate previousMonthEnd = columnDate.withDayOfMonth(1).minusDays(1);
|
||||
// Which fortnight the column falls in settles the base, not the day it carries. The first
|
||||
// fortnight closes on the 15th; everything after it closes on the month end.
|
||||
return columnDate.getDayOfMonth() <= 15 ? previousMonthEnd.withDayOfMonth(15) : previousMonthEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1579,7 +1777,10 @@ public class RbiReserveReturnApiResource {
|
||||
final LocalDate previousClose = requested.withDayOfMonth(1).minusDays(1);
|
||||
final LocalDate base = formOneBaseDate(currentClose);
|
||||
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose, base);
|
||||
// Two columns, as on Form 1. The base is stated against the lines it settles rather than
|
||||
// given a column, where its balances would read as a third period being reported.
|
||||
final List<LocalDate> columnDates = List.of(previousClose, currentClose);
|
||||
final List<LocalDate> baseDates = List.of(formOneBaseDate(previousClose), base);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("formCode", "FORM_9_STATUTORY");
|
||||
@@ -1590,6 +1791,7 @@ public class RbiReserveReturnApiResource {
|
||||
response.put("monthEnd", nominalMonthEnd.toString());
|
||||
response.put("monthComplete", monthComplete);
|
||||
response.put("columns", columnDates.stream().map(LocalDate::toString).toList());
|
||||
response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList());
|
||||
|
||||
final Rate crr = rateInForce("CRR", currentClose);
|
||||
final Rate slr = rateInForce("SLR", currentClose);
|
||||
@@ -1599,8 +1801,11 @@ public class RbiReserveReturnApiResource {
|
||||
// Part A establishes the liabilities both ratios are charged on, so it is needed here even
|
||||
// though only section 24 is settled below: without it the required amount at XI is a figure
|
||||
// with nothing behind it.
|
||||
response.put("partA", formOnePartA(columnDates, officeId, crr, slr));
|
||||
response.put("partC", formOnePartC(columnDates, officeId, slr));
|
||||
response.put("partA", formOnePartA(columnDates, baseDates, officeId, crr, slr));
|
||||
response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr));
|
||||
// Every day of the month, as on Form 1. Section 24 is reckoned over the period too, so a
|
||||
// month-end position that passes says nothing about the days behind it.
|
||||
response.put("daily", formOneDaily(currentClose.withDayOfMonth(1), currentClose, officeId, crr, slr));
|
||||
|
||||
final List<String> unmet = new ArrayList<>();
|
||||
if (slr == null) {
|
||||
@@ -1620,9 +1825,9 @@ public class RbiReserveReturnApiResource {
|
||||
// As on Form 1: a nil base makes the requirement nil, and the form then reads the same as a
|
||||
// bank with nothing to maintain. Said with the date so the nil can be read for what it is.
|
||||
if (formOneNetLiabilities(base, officeId).compareTo(BigDecimal.ZERO) == 0) {
|
||||
unmet.add("The ratio is charged on the net liabilities of " + base + " — the last Friday "
|
||||
+ "of the second preceding fortnight — and the ledger holds none on that date. "
|
||||
+ "The required amount is therefore nil, because the base is nil.");
|
||||
unmet.add("The ratio is charged on the net liabilities of " + base
|
||||
+ ", and the ledger holds none on that date. The required amount is therefore "
|
||||
+ "nil, because the base is nil.");
|
||||
}
|
||||
response.put("unmetRequirements", unmet);
|
||||
return gson.toJson(response);
|
||||
|
||||
+3
-1
@@ -95,7 +95,9 @@ public class RbiFormAAutoSaveService {
|
||||
// No column dates passed: today IS a fortnight close - the check above returned
|
||||
// otherwise - so the form derives its own pair from it, which is the pair an unattended
|
||||
// run should file. A filer needing a different period enters both dates on the screen.
|
||||
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null),
|
||||
// No base dates passed either: an unattended run files the base the rules fix, which is
|
||||
// what a saved return should carry. A filer needing a different base enters it on screen.
|
||||
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null, null, null),
|
||||
JsonObject.class);
|
||||
} catch (final RuntimeException e) {
|
||||
// A return that cannot be computed is not an error worth failing the batch over - an
|
||||
|
||||
+3
-3
@@ -580,7 +580,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -597,7 +597,7 @@ public class SavingsDailyInterestService {
|
||||
SELECT t.transaction_date AS d,
|
||||
SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END) AS net
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -613,7 +613,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
|
||||
+30
@@ -79,6 +79,7 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
|
||||
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -103,6 +104,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -277,9 +279,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
} else {
|
||||
// Paid out in cash or to a GL, so the interest never reaches savings: TDS is tracked and taken here.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -400,10 +406,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
account.updateClosedStatus();
|
||||
account.updateOnAccountClosureStatus(onClosureType);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
// if(!processMaturityInstructionOnly)
|
||||
@@ -483,9 +492,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -855,6 +867,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else if (closureType.isTransferToGl()) {
|
||||
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
|
||||
// Taken before the proceeds are read, so the GL receives the payout net of TDS.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"GL");
|
||||
final BigDecimal proceeds = account.getAccountBalance();
|
||||
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
|
||||
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
|
||||
@@ -865,15 +880,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
isRegularTransaction, isInternalTransfer);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
|
||||
command.stringValueOfParameterNamed(transferDescriptionParamName));
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"CASH");
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
|
||||
// accounted for or the postJournalEntries() call below posts it a second time.
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
@@ -1134,10 +1153,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
}
|
||||
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
|
||||
if (alreadyMatured) {
|
||||
account.close(user, command, changes);
|
||||
@@ -1195,9 +1217,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(closureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
@@ -1222,6 +1247,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
||||
}
|
||||
|
||||
/** How a closure that does not go to savings paid out, for the TDS tracking record. */
|
||||
private static String directPayoutKind(final DepositAccountOnClosureType closureType) {
|
||||
return closureType != null && closureType.isTransferToGl() ? "GL" : "CASH";
|
||||
}
|
||||
|
||||
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
|
||||
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
|
||||
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
|
||||
|
||||
+2
-10
@@ -250,11 +250,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement elsewhere.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||
}
|
||||
|
||||
@@ -310,11 +306,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement elsewhere.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -260,11 +260,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -354,11 +350,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -264,11 +264,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -361,11 +357,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -203,11 +203,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||
}
|
||||
|
||||
@@ -269,11 +265,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -420,11 +420,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -528,11 +524,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-12
@@ -172,12 +172,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||
// it just no longer lets them bypass the hold on their own (matches
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||
// equivalent rule for the classic command pipeline).
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||
}
|
||||
|
||||
@@ -219,12 +214,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||
// it just no longer lets them bypass the hold on their own (matches
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||
// equivalent rule for the classic command pipeline).
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -882,11 +882,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -972,11 +968,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -281,7 +281,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, "
|
||||
+ "m.dd_limit_amount "
|
||||
+ "from m_sponsor_dd_gl_mapping m "
|
||||
+ "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
// Left join: DD Payable moved to the sponsor bank (0570) and became nullable here (0573), so
|
||||
// an inner join dropped the whole row and the settings screen reopened blank.
|
||||
+ "left join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
+ "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id "
|
||||
+ "left join acc_gl_account cash on cash.id = m.cash_gl_account_id "
|
||||
+ "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id "
|
||||
@@ -310,7 +312,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
final Long id = rs.getLong("id");
|
||||
final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null;
|
||||
final String sponsorBankName = rs.getString("sponsor_bank_name");
|
||||
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
|
||||
final Long ddPayableGlAccountId = rs.getObject("dd_payable_gl_account_id") != null ? rs.getLong("dd_payable_gl_account_id") : null;
|
||||
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
|
||||
final String ddPayableGlCode = rs.getString("dd_payable_gl_code");
|
||||
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
|
||||
|
||||
+2
-2
@@ -52,10 +52,10 @@ public class TdsInterestApiResource {
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Deposit interest per customer for a financial year",
|
||||
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear, @QueryParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser();
|
||||
final List<TdsDeducteeData> deductees = this.readPlatformService
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId);
|
||||
return this.toApiJsonSerializer.serialize(deductees);
|
||||
}
|
||||
|
||||
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 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.tds.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.service.TdsSql;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The two TDS records the bank keeps by hand: the declarations customers give at the counter, and the
|
||||
* challans Head Office remits the deducted tax with.
|
||||
*
|
||||
* <p>
|
||||
* Both feed the deduction and the reports directly. A declaration stops the deduction from the day it
|
||||
* is accepted; a challan is what the remittance report reconciles the deducted tax against.
|
||||
*/
|
||||
@Path("/v1/tds")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class TdsRecordsApiResource {
|
||||
|
||||
private static final String DECLARATION = "TDSDECLARATION";
|
||||
private static final String CHALLAN = "TDSCHALLAN";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ─────────────────────────── declarations ───────────────────────────
|
||||
|
||||
/** A customer's declarations, every year and revoked ones included, newest year first. */
|
||||
@GET
|
||||
@Path("declarations")
|
||||
public String declarations(@QueryParam("clientId") final Long clientId, @QueryParam("fy") final String fy) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (clientId == null && fy == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declarations.filter.required", "Give a clientId or a fy.");
|
||||
}
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final StringBuilder where = new StringBuilder(" WHERE o.hierarchy LIKE ?");
|
||||
params.add(user.getOffice().getHierarchy() + "%");
|
||||
if (clientId != null) {
|
||||
where.append(" AND d.client_id = ?");
|
||||
params.add(clientId);
|
||||
}
|
||||
if (fy != null) {
|
||||
where.append(" AND d.fy_label = ?");
|
||||
params.add(fy);
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT d.id, d.client_id, c.display_name AS client_name, d.fy_label, d.form_type, d.uin, d.accepted_on,
|
||||
d.estimated_income, d.is_revoked, d.revoked_on, d.revoke_reason
|
||||
FROM m_tds_declaration d
|
||||
JOIN m_client c ON c.id = d.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
""" + where + " ORDER BY d.fy_label DESC, d.accepted_on DESC", params.toArray());
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("clientId", r.get("client_id"));
|
||||
o.put("clientName", r.get("client_name"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("formType", r.get("form_type"));
|
||||
o.put("uin", r.get("uin"));
|
||||
o.put("acceptedOn", str(r.get("accepted_on")));
|
||||
o.put("estimatedIncome", r.get("estimated_income"));
|
||||
o.put("revoked", r.get("is_revoked"));
|
||||
o.put("revokedOn", str(r.get("revoked_on")));
|
||||
o.put("revokeReason", r.get("revoke_reason"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a declaration. One live declaration per customer per year; the form must be the one the
|
||||
* year's configuration names (Form 121 from 2026-27, 15G/15H before), the date must fall in the year
|
||||
* and not in the future, and the customer must have a PAN - a declaration without one is invalid.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createDeclaration(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final Long clientId = requiredLong(root, "clientId");
|
||||
assertInScope(user, clientId);
|
||||
final String fy = requiredText(root, "fy");
|
||||
final String formType = requiredText(root, "formType").toUpperCase();
|
||||
final LocalDate acceptedOn = requiredDate(root, "acceptedOn");
|
||||
final String uin = optionalText(root, "uin");
|
||||
final BigDecimal estimatedIncome = root.has("estimatedIncome") && !root.get("estimatedIncome").isJsonNull()
|
||||
&& !root.get("estimatedIncome").getAsString().isBlank() ? root.get("estimatedIncome").getAsBigDecimal() : null;
|
||||
|
||||
final List<Map<String, Object>> cfg = jdbcTemplate.queryForList("SELECT declaration_form FROM m_tds_fy_config WHERE fy_label = ?",
|
||||
fy);
|
||||
if (cfg.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.fy.not.configured",
|
||||
"Financial year " + fy + " is not set up under Organization › TDS Configuration.");
|
||||
}
|
||||
final String yearForm = (String) cfg.get(0).get("declaration_form");
|
||||
final boolean merged = "121".equals(yearForm);
|
||||
if (merged ? !"121".equals(formType) : !("15G".equals(formType) || "15H".equals(formType))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.year",
|
||||
merged ? "From " + fy + " the declaration is Form 121." : "For " + fy + " the declaration is Form 15G or 15H.");
|
||||
}
|
||||
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate fyStart = LocalDate.of(startYear, 4, 1);
|
||||
final LocalDate fyEnd = LocalDate.of(startYear + 1, 3, 31);
|
||||
if (acceptedOn.isBefore(fyStart) || acceptedOn.isAfter(fyEnd)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.outside.year",
|
||||
"The acceptance date must fall inside " + fy + ".");
|
||||
}
|
||||
if (acceptedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.in.future",
|
||||
"A declaration cannot be accepted on a future date.");
|
||||
}
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", clientId);
|
||||
if (!TdsSql.isWellFormedPan((String) person.get("pan"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.pan.required",
|
||||
"A declaration is valid only with the customer's PAN. Record a PAN on the KYC first.");
|
||||
}
|
||||
if (!merged) {
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), fyEnd);
|
||||
if (senior != "15H".equals(formType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.age",
|
||||
senior ? "This customer is 60 or over in " + fy + ", so the form is 15H." : "This customer is under 60 in " + fy
|
||||
+ ", so the form is 15G.");
|
||||
}
|
||||
}
|
||||
|
||||
final Integer live = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_declaration WHERE client_id = ? AND fy_label = ? AND is_revoked = false", Integer.class,
|
||||
clientId, fy);
|
||||
if (live != null && live > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.duplicate",
|
||||
"This customer already has a declaration on record for " + fy + ". Revoke it first to replace it.");
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_declaration (client_id, fy_label, form_type, uin, accepted_on, estimated_income,
|
||||
is_revoked, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", clientId, fy, formType, uin, acceptedOn, estimatedIncome, user.getId());
|
||||
return declarations(clientId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraws a declaration from a date. Interest credited from then on is deducted again; what was
|
||||
* paid gross while it stood stays paid gross, because it was lawful at the time.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations/{id}/revoke")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String revokeDeclaration(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
final List<Long> owner = jdbcTemplate.queryForList("SELECT client_id FROM m_tds_declaration WHERE id = ? AND is_revoked = false",
|
||||
Long.class, id);
|
||||
if (owner.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.not.found", "No live declaration " + id + ".");
|
||||
}
|
||||
assertInScope(user, owner.get(0));
|
||||
final String reason = requiredText(root, "reason");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_tds_declaration
|
||||
SET is_revoked = true, revoked_on = ?, revoke_reason = ?, revoked_by = ?
|
||||
WHERE id = ?
|
||||
""", DateUtils.getBusinessLocalDate(), reason, user.getId(), id);
|
||||
return declarations(owner.get(0), null);
|
||||
}
|
||||
|
||||
// ─────────────────────────── challans ───────────────────────────
|
||||
|
||||
/** Every challan entered for the year, reversed ones included so the trail stays visible. */
|
||||
@GET
|
||||
@Path("challans")
|
||||
public String challans(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT ch.id, ch.fy_label, ch.deduction_month, ch.amount, ch.challan_no, ch.bsr_code, ch.deposited_on,
|
||||
ch.paid_from_gl_account_id, g.gl_code, g.name AS gl_name, ch.journal_transaction_id, ch.remarks,
|
||||
ch.is_reversed
|
||||
FROM m_tds_challan ch
|
||||
JOIN acc_gl_account g ON g.id = ch.paid_from_gl_account_id
|
||||
WHERE ch.fy_label = ?
|
||||
ORDER BY ch.deduction_month, ch.deposited_on, ch.id
|
||||
""", fy);
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("month", r.get("deduction_month"));
|
||||
o.put("amount", r.get("amount"));
|
||||
o.put("challanNo", r.get("challan_no"));
|
||||
o.put("bsrCode", r.get("bsr_code"));
|
||||
o.put("depositedOn", str(r.get("deposited_on")));
|
||||
o.put("paidFromGlAccountId", r.get("paid_from_gl_account_id"));
|
||||
o.put("paidFrom", r.get("gl_code") + " · " + r.get("gl_name"));
|
||||
o.put("journalTransactionId", r.get("journal_transaction_id"));
|
||||
o.put("remarks", r.get("remarks"));
|
||||
o.put("reversed", r.get("is_reversed"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax actually deducted, month by month, for the year: the TDS debits on savings accounts for FD/RD
|
||||
* interest received there. This is what the challans must add up to.
|
||||
*/
|
||||
@GET
|
||||
@Path("deducted")
|
||||
public String deducted(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
requireFy(fy);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT TO_CHAR(d.deducted_on, 'YYYY-MM') AS month, SUM(d.amount) AS amount, COUNT(*) AS postings
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.fy_label = ?
|
||||
GROUP BY 1 ORDER BY 1
|
||||
""", fy);
|
||||
// The business date, not the wall clock: whether a challan is overdue is judged on the day the
|
||||
// books are on.
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("asAt", DateUtils.getBusinessLocalDate().toString());
|
||||
out.put("months", rows);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/** Accounts the tax can be paid out of: active, postable asset accounts (bank, cash). */
|
||||
@GET
|
||||
@Path("challans/template")
|
||||
public String challanTemplate() {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT id, gl_code AS "glCode", name FROM acc_gl_account
|
||||
WHERE disabled = false AND account_usage = 1 AND classification_enum = 1
|
||||
ORDER BY gl_code
|
||||
""");
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("paidFromOptions", accounts);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a remittance and posts it: Dr TDS payable / Cr the account paid from, at Head Office.
|
||||
* Only Head Office remits - the bank files one return under one TAN.
|
||||
*/
|
||||
@POST
|
||||
@Path("challans")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createChallan(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final String fy = requireFy(requiredText(root, "fy"));
|
||||
final String month = requiredText(root, "month");
|
||||
if (!month.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.invalid", "Month must look like 2026-06.");
|
||||
}
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate monthStart = LocalDate.parse(month + "-01");
|
||||
if (monthStart.isBefore(LocalDate.of(startYear, 4, 1)) || monthStart.isAfter(LocalDate.of(startYear + 1, 3, 1))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.outside.year", "The month must fall inside " + fy + ".");
|
||||
}
|
||||
final BigDecimal amount = root.get("amount").getAsBigDecimal();
|
||||
if (amount.signum() <= 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.amount.invalid", "Amount must be more than zero.");
|
||||
}
|
||||
final String challanNo = requiredText(root, "challanNo");
|
||||
final String bsrCode = requiredText(root, "bsrCode");
|
||||
if (!bsrCode.matches("\\d{7}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.bsr.invalid", "BSR code is 7 digits.");
|
||||
}
|
||||
final LocalDate depositedOn = requiredDate(root, "depositedOn");
|
||||
if (depositedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.date.in.future", "Deposit date cannot be in the future.");
|
||||
}
|
||||
final Long paidFrom = requiredLong(root, "paidFromGlAccountId");
|
||||
final String remarks = optionalText(root, "remarks");
|
||||
|
||||
final Integer dup = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_challan WHERE challan_no = ? AND bsr_code = ? AND deposited_on = ? AND is_reversed = false",
|
||||
Integer.class, challanNo, bsrCode, depositedOn);
|
||||
if (dup != null && dup > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.duplicate", "This challan is already on record.");
|
||||
}
|
||||
|
||||
final Long tdsPayable = tdsPayableGl();
|
||||
final long officeId = user.getOffice().getId();
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final String txnId = "TDSCH-" + UUID.randomUUID().toString().substring(0, 18);
|
||||
final String narration = "TDS remitted for " + month + " · challan " + challanNo + " · BSR " + bsrCode;
|
||||
journalEntry(officeId, currency, tdsPayable, depositedOn, amount, true, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, paidFrom, depositedOn, amount, false, txnId, narration, user.getId());
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_challan (fy_label, deduction_month, amount, challan_no, bsr_code, deposited_on,
|
||||
paid_from_gl_account_id, office_id, journal_transaction_id, remarks,
|
||||
is_reversed, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", fy, month, amount, challanNo, bsrCode, depositedOn, paidFrom, officeId, txnId, remarks, user.getId());
|
||||
return challans(fy);
|
||||
}
|
||||
|
||||
/** Undoes a challan entered in error: the entry is reversed with an opposite one, the row kept. */
|
||||
@POST
|
||||
@Path("challans/{id}/reverse")
|
||||
@Transactional
|
||||
public String reverseChallan(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT fy_label, amount, paid_from_gl_account_id, office_id, journal_transaction_id, challan_no FROM m_tds_challan"
|
||||
+ " WHERE id = ? AND is_reversed = false",
|
||||
id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.not.found", "No live challan " + id + ".");
|
||||
}
|
||||
final Map<String, Object> ch = rows.get(0);
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final String txnId = "TDSCHR-" + UUID.randomUUID().toString().substring(0, 17);
|
||||
final String narration = "Reversal of TDS challan " + ch.get("challan_no");
|
||||
final long officeId = ((Number) ch.get("office_id")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) ch.get("amount");
|
||||
journalEntry(officeId, currency, tdsPayableGl(), today, amount, false, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, ((Number) ch.get("paid_from_gl_account_id")).longValue(), today, amount, true, txnId, narration,
|
||||
user.getId());
|
||||
jdbcTemplate.update("UPDATE m_tds_challan SET is_reversed = true WHERE id = ?", id);
|
||||
return challans((String) ch.get("fy_label"));
|
||||
}
|
||||
|
||||
// ─────────────────────────── helpers ───────────────────────────
|
||||
|
||||
/** Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. */
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final boolean debit, final String reference, final String narration, final Long userId) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
||||
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
|
||||
reversed, manual_entry, office_running_balance, organization_running_balance,
|
||||
created_by, last_modified_by, created_date, lastmodified_date, created_on_utc,
|
||||
last_modified_on_utc, submitted_on_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, false, false, true, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, userId, userId, date);
|
||||
}
|
||||
|
||||
private Long tdsPayableGl() {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.payable.gl.not.configured",
|
||||
"No TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
return gl.get(0);
|
||||
}
|
||||
|
||||
private void assertHeadOffice(final AppUser user) {
|
||||
if (user.getOffice().getParent() != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.head.office.only",
|
||||
"TDS is remitted by Head Office. A branch login cannot record a challan.");
|
||||
}
|
||||
}
|
||||
|
||||
/** A user may act only on customers of their own office or the offices below it. */
|
||||
private void assertInScope(final AppUser user, final Long clientId) {
|
||||
final Integer n = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_client c JOIN m_office o ON o.id = c.office_id
|
||||
WHERE c.id = ? AND o.hierarchy LIKE ?
|
||||
""", Integer.class, clientId, user.getOffice().getHierarchy() + "%");
|
||||
if (n == null || n == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.client.out.of.scope",
|
||||
"Customer " + clientId + " does not belong to your office.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireFy(final String fy) {
|
||||
if (fy == null || !fy.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.fy.invalid", "Financial year must look like 2026-27.");
|
||||
}
|
||||
return fy;
|
||||
}
|
||||
|
||||
private static String str(final Object v) {
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static String optionalText(final JsonObject root, final String name) {
|
||||
if (!root.has(name) || root.get(name).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String v = root.get(name).getAsString().trim();
|
||||
return v.isEmpty() ? null : v;
|
||||
}
|
||||
|
||||
private static String requiredText(final JsonObject root, final String name) {
|
||||
final String v = optionalText(root, name);
|
||||
if (v == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".required", name + " is required.");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static Long requiredLong(final JsonObject root, final String name) {
|
||||
try {
|
||||
return Long.valueOf(requiredText(root, name));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a number.");
|
||||
}
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd. */
|
||||
private static LocalDate requiredDate(final JsonObject root, final String name) {
|
||||
try {
|
||||
return LocalDate.parse(requiredText(root, name));
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a date like 2026-09-15.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** A 15G / 15H / Form 121 declaration: one customer, one financial year, from the day it was accepted. */
|
||||
public record TdsDeclarationData(Long id, Long clientId, String fy, String formType, String uin, LocalDate acceptedOn,
|
||||
BigDecimal estimatedIncome, boolean revoked, LocalDate revokedOn, String revokeReason) implements Serializable {}
|
||||
+18
-9
@@ -19,12 +19,13 @@
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A customer whose deposit interest the branch must consider for deduction under section 194A,
|
||||
* together with the accounts and the interest actually credited to them.
|
||||
* A customer whose deposit interest the branch must consider for deduction, together with the
|
||||
* accounts and the interest actually credited to them.
|
||||
*
|
||||
* <p>
|
||||
* The section bites on time deposits, so savings accounts are carried here as well but marked by
|
||||
@@ -32,23 +33,31 @@ import java.util.List;
|
||||
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
|
||||
* customer holds a savings balance at all, which is the first thing an officer checks when a
|
||||
* customer disputes a deduction.
|
||||
*
|
||||
* <p>
|
||||
* The customer here is the <em>deductee</em>: a minor's deposit is listed under the guardian and a
|
||||
* joint deposit under its primary holder, because that is whose PAN the tax is deducted against.
|
||||
*/
|
||||
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
|
||||
String branchName, List<TdsDeclarationData> declarations, List<TdsAccountData> accounts) implements Serializable {
|
||||
|
||||
/**
|
||||
* One deposit account and the interest posted to it.
|
||||
* One deposit account, the interest posted to it and the TDS actually deducted from it.
|
||||
*
|
||||
* @param depositTypeCode
|
||||
* Fineract's own code - {@code depositAccountType.fixedDeposit},
|
||||
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
|
||||
* the charge applies at all, so it is sent as the platform states it rather than as
|
||||
* a flag this report worked out for itself.
|
||||
* @param clientId
|
||||
* the deductee, which is the account holder unless the account is a minor's or joint
|
||||
* @param holderClientId
|
||||
* the account's own holder, shown when it differs from the deductee
|
||||
*/
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
|
||||
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId,
|
||||
String holderName, String accountStatus, LocalDate openedOn, List<TdsInterestCreditData> credits,
|
||||
List<TdsInterestCreditData> deductions) implements Serializable {}
|
||||
|
||||
/** One interest posting: the date it was credited and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
|
||||
implements Serializable {}
|
||||
/** One interest posting, or one TDS deduction: the date and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, BigDecimal amount, String accountNo) implements Serializable {}
|
||||
}
|
||||
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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.tds.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponent;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* TDS on FD/RD interest, tracked and deducted where the interest is received.
|
||||
*
|
||||
* <p>
|
||||
* Normally that is the customer's savings account: every time money moves from a fixed or recurring
|
||||
* deposit into savings, the interest in it is recorded against the deductee (see
|
||||
* {@link TdsSql#deducteeOf}) and any TDS due is debited from that savings account. The periodic
|
||||
* interest sweep is all interest; a maturity or closure payout is principal and interest together, so
|
||||
* only the interest not already received is counted. The FD and RD are not debited.
|
||||
*
|
||||
* <p>
|
||||
* When a maturity or closure pays out in cash, by cheque or to a GL account instead, the interest never
|
||||
* reaches savings, so it is tracked at the payout and the TDS comes out of the payout itself. A full
|
||||
* reinvestment pays nothing out and is not taxed here.
|
||||
*
|
||||
* <p>
|
||||
* The rules, all for one financial year and one deductee:
|
||||
* <ol>
|
||||
* <li>The FD/RD interest received, across every branch, is added up. The savings account's own interest
|
||||
* is not - it is outside the charge.</li>
|
||||
* <li>A declaration shields the interest received from the day it is accepted. Interest received
|
||||
* before it stays liable.</li>
|
||||
* <li>Nothing is deducted while the total stays within the threshold (senior or general).</li>
|
||||
* <li>Once over it, the whole year's interest is liable - not merely the excess - at the PAN rate, or
|
||||
* the higher rate without a valid PAN.</li>
|
||||
* <li>What is deducted now is that liability less everything already deducted this year, so the credit
|
||||
* that crosses the threshold catches up the earlier ones, and nothing is ever deducted twice.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* The tax is posted as a withhold-tax transaction through a tax component whose credit account is the
|
||||
* TDS payable account set on Organization › TDS Configuration, so the passbook shows it and the
|
||||
* account's own accounting writes Dr deposit liability / Cr TDS payable.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SavingsInterestTdsService {
|
||||
|
||||
/** The tax component every TDS deduction is booked through. Kept in step with the GL setting. */
|
||||
public static final String TDS_COMPONENT_NAME = "TDS on deposit interest";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final TaxComponentRepository taxComponentRepository;
|
||||
private final EntityManager entityManager;
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepository;
|
||||
private final SavingsAccountTransactionRepository savingsAccountTransactionRepository;
|
||||
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
|
||||
|
||||
/**
|
||||
* Called once a savings-to-savings transfer has been saved.
|
||||
*
|
||||
* @param deposit
|
||||
* the credit on the receiving savings account
|
||||
*/
|
||||
public void onTransfer(final SavingsAccount from, final SavingsAccount to, final SavingsAccountTransaction deposit,
|
||||
final AccountTransferType transferType) {
|
||||
if (from == null || to == null || deposit == null || deposit.getId() == null || !isTimeDeposit(from)) {
|
||||
return;
|
||||
}
|
||||
final DepositAccountType toType = to.depositAccountType();
|
||||
if (toType == null || !toType.isSavingsDeposit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final boolean sweep = transferType != null && transferType.isInterestTransfer();
|
||||
final BigDecimal interest = sweep ? deposit.getAmount()
|
||||
: interestInPayout(from.getId(), postedFromDatabase(from.getId()), deposit.getAmount());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDate date = deposit.getTransactionDate();
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(from, year);
|
||||
if (deductee == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Long receiptId = insertReceipt(deductee, to.getId(), deposit.getId(), from.getId(), sweep ? "INTEREST" : "PAYOUT", year,
|
||||
date, interest);
|
||||
|
||||
final Due due = dueFor(deductee, year, date, BigDecimal.ZERO);
|
||||
if (due == null) {
|
||||
return;
|
||||
}
|
||||
final Set<Long> existingIds = new HashSet<>(to.findExistingTransactionIds());
|
||||
final Set<Long> existingReversedIds = new HashSet<>(to.findExistingReversedTransactionIds());
|
||||
final SavingsAccountTransaction tds = to.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
// Saved on its own first, as Fineract does for every new transaction: saving only the account
|
||||
// merges it, and the id lands on the merged copy - this instance stays null, which broke both
|
||||
// the deduction row and the journal entry's transaction reference.
|
||||
this.savingsAccountTransactionRepository.saveAndFlush(tds);
|
||||
this.savingsAccountRepository.saveAndFlush(to);
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(
|
||||
to.deriveAccountingBridgeData(to.getCurrency().getCode(), existingIds, existingReversedIds, false, false));
|
||||
recordDeduction(deductee, to.getId(), tds.getId(), from.getId(), receiptId, year, date, due);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest paid straight out of an FD/RD at maturity or closure - in cash, by cheque, or to a GL
|
||||
* account - never reaches a savings account, so it is tracked here instead, and any TDS it makes due
|
||||
* is taken out of the payout itself.
|
||||
*
|
||||
* <p>
|
||||
* Call just before the closing withdrawal is worked out: the deduction is added to the deposit, so a
|
||||
* withdrawal of the account balance pays out what is left. The deposit is not saved here - the
|
||||
* closing withdrawal saves it and posts the deduction's accounting along with its own. Then call
|
||||
* {@link #recordDirectPayout}.
|
||||
*
|
||||
* @param kind
|
||||
* CASH or GL, for the tracking record
|
||||
* @return what to record once the withdrawal exists, or null when the payout carried no interest
|
||||
*/
|
||||
public DirectPayout deductOnDirectPayout(final SavingsAccount deposit, final LocalDate date, final String kind) {
|
||||
if (deposit == null || deposit.getId() == null || date == null || !isTimeDeposit(deposit)) {
|
||||
return null;
|
||||
}
|
||||
// From memory: the maturity or pre-closure interest posted moments ago is not saved yet.
|
||||
BigDecimal posted = BigDecimal.ZERO;
|
||||
for (final SavingsAccountTransaction t : deposit.getTransactions()) {
|
||||
if (t.isInterestPostingAndNotReversed() && !t.isReversalTransaction()) {
|
||||
posted = posted.add(t.getAmount());
|
||||
}
|
||||
}
|
||||
final BigDecimal interest = interestInPayout(deposit.getId(), posted, deposit.getAccountBalance());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(deposit, year);
|
||||
if (deductee == null) {
|
||||
return null;
|
||||
}
|
||||
final Due due = dueFor(deductee, year, date, interest);
|
||||
final SavingsAccountTransaction tds = due == null ? null
|
||||
: deposit.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
return new DirectPayout(deposit.getId(), deductee, year, date, interest, kind, due, tds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the tracking row and the deduction for a direct payout, once the closing withdrawal has been
|
||||
* saved, and marks the deduction as already posted so the caller's own journal run does not post it a
|
||||
* second time - the withdrawal posted it.
|
||||
*/
|
||||
public void recordDirectPayout(final DirectPayout payout, final SavingsAccountTransaction withdrawal,
|
||||
final Set<Long> alreadyPostedTransactionIds) {
|
||||
if (payout == null || withdrawal == null || withdrawal.getId() == null) {
|
||||
return;
|
||||
}
|
||||
final Long receiptId = insertReceipt(payout.deductee(), payout.accountId(), withdrawal.getId(), payout.accountId(), payout.kind(),
|
||||
payout.year(), payout.date(), payout.interest());
|
||||
if (payout.tds() != null && payout.tds().getId() != null) {
|
||||
recordDeduction(payout.deductee(), payout.accountId(), payout.tds().getId(), payout.accountId(), receiptId, payout.year(),
|
||||
payout.date(), payout.due());
|
||||
if (alreadyPostedTransactionIds != null) {
|
||||
alreadyPostedTransactionIds.add(payout.tds().getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What {@link #deductOnDirectPayout} worked out, held until the withdrawal is saved. */
|
||||
public record DirectPayout(Long accountId, Long deductee, Year year, LocalDate date, BigDecimal interest, String kind, Due due,
|
||||
SavingsAccountTransaction tds) {}
|
||||
|
||||
/** TDS due now, and what it was worked out on. */
|
||||
public record Due(BigDecimal amount, BigDecimal ratePercent, BigDecimal liableInterest, TaxComponent component) {}
|
||||
|
||||
/** One Indian financial year. */
|
||||
public record Year(LocalDate start, LocalDate end, String label) {
|
||||
|
||||
static Year of(final LocalDate date) {
|
||||
final int startYear = TdsSql.fyStartYear(date);
|
||||
return new Year(LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31), TdsSql.fyLabel(startYear));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTimeDeposit(final SavingsAccount account) {
|
||||
final DepositAccountType type = account.depositAccountType();
|
||||
return type != null && (type.isFixedDeposit() || type.isRecurringDeposit());
|
||||
}
|
||||
|
||||
private Long deducteeOf(final SavingsAccount deposit, final Year year) {
|
||||
return jdbcTemplate.queryForObject("SELECT " + TdsSql.deducteeOf("sa") + " FROM m_savings_account sa WHERE sa.id = ?", Long.class,
|
||||
year.start(), deposit.getId());
|
||||
}
|
||||
|
||||
private Long insertReceipt(final Long deductee, final Long receivingAccountId, final Long txnId, final Long sourceAccountId,
|
||||
final String kind, final Year year, final LocalDate date, final BigDecimal interest) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tds_interest_receipt (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id,
|
||||
kind, fy_label, received_on, interest_amount, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, now()) RETURNING id
|
||||
""", Long.class, deductee, receivingAccountId, txnId, sourceAccountId, kind, year.label(), date, interest);
|
||||
}
|
||||
|
||||
private void recordDeduction(final Long deductee, final Long accountId, final Long txnId, final Long sourceAccountId,
|
||||
final Long receiptId, final Year year, final LocalDate date, final Due due) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_deduction (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, receipt_id,
|
||||
fy_label, deducted_on, amount, rate_percentage, interest_base, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
|
||||
""", deductee, accountId, txnId, sourceAccountId, receiptId, year.label(), date, due.amount(), due.ratePercent(),
|
||||
due.liableInterest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The TDS due for the deductee now, or null when nothing is.
|
||||
*
|
||||
* @param extraInterest
|
||||
* interest received on {@code date} that is not on record yet
|
||||
*/
|
||||
private Due dueFor(final Long deductee, final Year year, final LocalDate date, final BigDecimal extraInterest) {
|
||||
final List<Map<String, Object>> cfgRows = jdbcTemplate.queryForList("""
|
||||
SELECT threshold_general, threshold_senior, rate_pan_percentage, rate_no_pan_percentage, round_to
|
||||
FROM m_tds_fy_config WHERE fy_label = ?
|
||||
""", year.label());
|
||||
if (cfgRows.isEmpty()) {
|
||||
// Tracked, but not deducted: the TDS screens show the same gap and the administrator fixes it there.
|
||||
log.warn("No TDS configuration for FY {}; interest for client {} tracked but not taxed", year.label(), deductee);
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> cfg = cfgRows.get(0);
|
||||
|
||||
final List<LocalDate> accepted = jdbcTemplate.queryForList("""
|
||||
SELECT accepted_on FROM m_tds_declaration
|
||||
WHERE client_id = ? AND fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", LocalDate.class, deductee, year.label());
|
||||
final LocalDate shieldedFrom = accepted.isEmpty() ? year.end().plusDays(1) : accepted.get(0);
|
||||
|
||||
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0) AS interest,
|
||||
COALESCE(SUM(CASE WHEN r.received_on < ? THEN r.interest_amount END), 0) AS liable
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.deductee_client_id = ? AND r.fy_label = ? AND r.received_on <= ?
|
||||
""", shieldedFrom, deductee, year.label(), date);
|
||||
final BigDecimal yearInterest = ((BigDecimal) totals.get("interest")).add(extraInterest);
|
||||
final BigDecimal liableInterest = ((BigDecimal) totals.get("liable"))
|
||||
.add(date.isBefore(shieldedFrom) ? extraInterest : BigDecimal.ZERO);
|
||||
final BigDecimal deducted = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(d.amount), 0)
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.deductee_client_id = ? AND d.fy_label = ?
|
||||
""", BigDecimal.class, deductee, year.label());
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", deductee);
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), year.end());
|
||||
final BigDecimal threshold = (BigDecimal) cfg.get(senior ? "threshold_senior" : "threshold_general");
|
||||
if (yearInterest.compareTo(threshold) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final boolean panOk = TdsSql.isWellFormedPan((String) person.get("pan"));
|
||||
final BigDecimal ratePercent = (BigDecimal) cfg.get(panOk ? "rate_pan_percentage" : "rate_no_pan_percentage");
|
||||
final int roundTo = ((Number) cfg.get("round_to")).intValue();
|
||||
final BigDecimal liability = TdsSql.roundToMultiple(liableInterest.multiply(ratePercent).movePointLeft(2), roundTo);
|
||||
final BigDecimal already = deducted == null ? BigDecimal.ZERO : deducted;
|
||||
final BigDecimal amount = liability.subtract(already);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
log.info("TDS {} due for client {} (FY {}: interest {}, liability {}, already deducted {})", amount, deductee, year.label(),
|
||||
yearInterest, liability, already);
|
||||
return new Due(amount, ratePercent, liableInterest, tdsComponent(ratePercent));
|
||||
}
|
||||
|
||||
private BigDecimal postedFromDatabase(final Long accountId) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
|
||||
""", BigDecimal.class, accountId, TdsSql.INTEREST_POSTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* The interest inside a maturity or closure payout: interest posted on the deposit that has not been
|
||||
* received already - through the interest sweep or an earlier payout. Capped at the payout.
|
||||
*
|
||||
* <p>
|
||||
* On a premature closure the months already swept to savings stand as they were; only the interest
|
||||
* for the days since is paid out now, and only that is counted.
|
||||
*/
|
||||
private BigDecimal interestInPayout(final Long sourceAccountId, final BigDecimal posted, final BigDecimal payout) {
|
||||
final BigDecimal swept = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(att.amount), 0)
|
||||
FROM m_account_transfer_transaction att
|
||||
JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id
|
||||
WHERE d.from_savings_account_id = ? AND d.transfer_type = ? AND att.is_reversed = false
|
||||
""", BigDecimal.class, sourceAccountId, AccountTransferType.INTEREST_TRANSFER.getValue());
|
||||
final BigDecimal paidOut = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0)
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.source_account_id = ? AND r.kind <> 'INTEREST'
|
||||
""", BigDecimal.class, sourceAccountId);
|
||||
final BigDecimal unpaid = posted.subtract(swept).subtract(paidOut);
|
||||
if (unpaid.compareTo(BigDecimal.ZERO) <= 0 || payout == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return unpaid.min(payout);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tax component the deduction is booked through, created on first use and re-pointed whenever
|
||||
* the TDS payable account on the configuration screen changes.
|
||||
*/
|
||||
private TaxComponent tdsComponent(final BigDecimal ratePercent) {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.tds.payable.gl.not.configured",
|
||||
"TDS is due on this interest but no TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
final Long glId = gl.get(0);
|
||||
final List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM m_tax_component WHERE name = ? ORDER BY id", Long.class,
|
||||
TDS_COMPONENT_NAME);
|
||||
final Long id;
|
||||
boolean repointed = false;
|
||||
if (existing.isEmpty()) {
|
||||
id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tax_component (name, percentage, credit_account_type_enum, credit_account_id, start_date,
|
||||
createdby_id, created_date, lastmodifiedby_id, lastmodified_date)
|
||||
SELECT ?, ?, 2, ?, DATE '2000-01-01', MIN(u.id), now(), MIN(u.id), now() FROM m_appuser u
|
||||
RETURNING id
|
||||
""", Long.class, TDS_COMPONENT_NAME, ratePercent, glId);
|
||||
} else {
|
||||
id = existing.get(0);
|
||||
repointed = jdbcTemplate.update("UPDATE m_tax_component SET credit_account_type_enum = 2, credit_account_id = ? WHERE id = ?"
|
||||
+ " AND (credit_account_id IS DISTINCT FROM ? OR credit_account_type_enum IS DISTINCT FROM 2)", glId, id, glId) > 0;
|
||||
}
|
||||
final TaxComponent component = taxComponentRepository.findById(id).orElseThrow();
|
||||
// Written behind JPA's back, so a copy already in this session would still credit the old account.
|
||||
if (repointed) {
|
||||
entityManager.refresh(component);
|
||||
}
|
||||
return component;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -29,6 +29,8 @@ public interface TdsInterestReadPlatformService {
|
||||
* @param financialYear
|
||||
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
|
||||
* 31 Mar 2027
|
||||
* @param clientId
|
||||
* one customer only, or null for everyone in the user's scope
|
||||
*/
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear, Long clientId);
|
||||
}
|
||||
|
||||
+122
-65
@@ -27,6 +27,8 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeclarationData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
|
||||
@@ -35,8 +37,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
|
||||
* rather than from a balance and a rate.
|
||||
* Reads the interest the bank actually credited, and the TDS it actually deducted, so the register is
|
||||
* worked out from the ledger rather than from a balance and a rate.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -44,109 +46,150 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
|
||||
* Every account, grouped under the client whose PAN carries its deduction, with what counts for TDS
|
||||
* inside the year.
|
||||
*
|
||||
* <p>
|
||||
* This single condition is what makes the figure an interest figure. A deposit is the
|
||||
* customer's own money arriving and is not income; a withdrawal and a fee are not income
|
||||
* either. Only what the bank itself credited as interest can be deducted against, so the
|
||||
* transaction type is filtered here rather than being inferred later from an amount.
|
||||
*
|
||||
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
|
||||
*/
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
|
||||
/**
|
||||
* Every deposit the customer holds, with the interest credited to each inside the year.
|
||||
* On a fixed or recurring deposit, "interest" is the FD/RD interest that reached the customer's
|
||||
* savings account (m_tds_interest_receipt) and "deductions" the TDS debited there for it
|
||||
* (m_tds_deduction) - TDS is tracked and taken where the interest is received, and the deposit
|
||||
* itself is never debited. Interest still sitting in a deposit is not counted until it is paid out.
|
||||
* Rows whose savings transaction has been reversed are left out: reversed money was never received.
|
||||
*
|
||||
* <p>
|
||||
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
|
||||
* keeps their interest out of the aggregate - but the branch still has to see that the customer
|
||||
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
|
||||
* On a savings account, "interest" is its own interest postings. The charge does not reach them, and
|
||||
* the caller keeps them out of the aggregate - but the branch still has to see them.
|
||||
*
|
||||
* <p>
|
||||
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
|
||||
* deduct tax on interest the customer never received.
|
||||
* Scoped to the signed-in user's office and the offices below it, on the deductee's office. A branch
|
||||
* login therefore receives only its own customers. Each customer still arrives with every one of
|
||||
* their deposits, wherever held, because the threshold is tested on the bank as a whole.
|
||||
*/
|
||||
private static final String SQL = """
|
||||
WITH acc AS (
|
||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.activatedon_date, sa.account_status,
|
||||
sa.client_id AS holder_id, %s AS deductee_id
|
||||
FROM m_savings_account sa
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
),
|
||||
lines AS (
|
||||
SELECT t.savings_account_id AS account_id, 'C' AS kind, t.transaction_date AS d, t.amount AS amt
|
||||
FROM m_savings_account_transaction t
|
||||
JOIN m_savings_account s ON s.id = t.savings_account_id AND s.deposit_type_enum = 100
|
||||
WHERE t.transaction_type_enum = ? AND t.is_reversed = false AND t.transaction_date BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT r.source_account_id, 'C', r.received_on, r.interest_amount
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.received_on BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT dd.source_account_id, 'D', dd.deducted_on, dd.amount
|
||||
FROM m_tds_deduction dd
|
||||
JOIN m_savings_account_transaction t ON t.id = dd.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE dd.deducted_on BETWEEN ? AND ?
|
||||
)
|
||||
SELECT c.id AS client_id,
|
||||
c.account_no AS customer_id,
|
||||
c.display_name AS name,
|
||||
c.date_of_birth AS date_of_birth,
|
||||
k.pan_number AS pan,
|
||||
k.form15_type AS form15_type,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan,
|
||||
o.name AS branch_name,
|
||||
sa.id AS account_id,
|
||||
sa.account_no AS account_no,
|
||||
sa.activatedon_date AS opened_on,
|
||||
CASE sa.deposit_type_enum
|
||||
a.id AS account_id,
|
||||
a.account_no AS account_no,
|
||||
a.activatedon_date AS opened_on,
|
||||
a.account_status AS account_status,
|
||||
a.holder_id AS holder_id,
|
||||
h.display_name AS holder_name,
|
||||
CASE a.deposit_type_enum
|
||||
WHEN 100 THEN 'depositAccountType.savingsDeposit'
|
||||
WHEN 200 THEN 'depositAccountType.fixedDeposit'
|
||||
WHEN 300 THEN 'depositAccountType.recurringDeposit'
|
||||
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
|
||||
t.transaction_date AS credit_date,
|
||||
t.amount AS credit_amount
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.savings_account_id = sa.id
|
||||
AND t.transaction_type_enum = ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_date >= ?
|
||||
AND t.transaction_date <= ?
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
ORDER BY c.display_name, sa.account_no, t.transaction_date
|
||||
""";
|
||||
l.kind AS line_kind,
|
||||
l.d AS txn_date,
|
||||
l.amt AS txn_amount
|
||||
FROM acc a
|
||||
JOIN m_client c ON c.id = a.deductee_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
JOIN m_client h ON h.id = a.holder_id
|
||||
LEFT JOIN lines l ON l.account_id = a.id
|
||||
WHERE o.hierarchy LIKE ?
|
||||
AND (CAST(? AS BIGINT) IS NULL OR c.id = CAST(? AS BIGINT))
|
||||
ORDER BY c.display_name, a.account_no, l.d
|
||||
""".formatted(TdsSql.deducteeOf("sa"));
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear, final Long onlyClientId) {
|
||||
// The Indian financial year, 1 April to 31 March.
|
||||
final LocalDate from = LocalDate.of(financialYear, 4, 1);
|
||||
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
|
||||
final java.sql.Date f = java.sql.Date.valueOf(from);
|
||||
final java.sql.Date t = java.sql.Date.valueOf(to);
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||
|
||||
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
|
||||
|
||||
jdbcTemplate.query(SQL, rs -> {
|
||||
final long clientId = rs.getLong("client_id");
|
||||
final Deductee deductee = byClient.computeIfAbsent(clientId,
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
|
||||
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
|
||||
readString(rs, "form15_type")));
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), readString(rs, "pan"),
|
||||
readDate(rs, "date_of_birth"), readString(rs, "branch_name")));
|
||||
|
||||
final long accountId = rs.getLong("account_id");
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
|
||||
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId,
|
||||
id -> new Account(id, readString(rs, "account_no"), readString(rs, "deposit_type_code"), readLong(rs, "holder_id"),
|
||||
readString(rs, "holder_name"), readString(rs, "account_status"), readDate(rs, "opened_on")));
|
||||
|
||||
// Null where the outer join found no posting inside the year: the account is still
|
||||
// listed, it simply earned nothing in the window.
|
||||
final java.sql.Date creditDate = rs.getDate("credit_date");
|
||||
if (creditDate != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("credit_amount");
|
||||
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
|
||||
// Null where the outer join found nothing inside the year: the account is still listed, it
|
||||
// simply earned nothing in the window.
|
||||
final java.sql.Date date = rs.getDate("txn_date");
|
||||
if (date != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("txn_amount");
|
||||
final TdsInterestCreditData line = new TdsInterestCreditData(date.toLocalDate(), amount, account.accountNo);
|
||||
if ("D".equals(rs.getString("line_kind"))) {
|
||||
account.deductions.add(line);
|
||||
} else {
|
||||
account.credits.add(line);
|
||||
}
|
||||
}
|
||||
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
|
||||
}, f, TdsSql.INTEREST_POSTING, f, t, f, t, f, t, hierarchy, onlyClientId, onlyClientId);
|
||||
|
||||
final Map<Long, List<TdsDeclarationData>> declarations = declarationsFor(TdsSql.fyLabel(financialYear));
|
||||
|
||||
final List<TdsDeducteeData> result = new ArrayList<>();
|
||||
for (final Deductee d : byClient.values()) {
|
||||
final List<TdsAccountData> accounts = new ArrayList<>();
|
||||
for (final Account a : d.accounts.values()) {
|
||||
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
|
||||
// to decide whose PAN carries the deduction. Fineract does not model it on the
|
||||
// savings account, so it is left null and the caller falls back to the account
|
||||
// holder rather than inventing a joint holder who may not exist.
|
||||
accounts.add(
|
||||
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
|
||||
accounts.add(new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, d.clientId, a.holderId, a.holderName, a.accountStatus,
|
||||
a.openedOn, a.credits, a.deductions));
|
||||
}
|
||||
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
|
||||
d.form15Type, accounts));
|
||||
declarations.getOrDefault(d.clientId, List.of()), accounts));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, List<TdsDeclarationData>> declarationsFor(final String fy) {
|
||||
final Map<Long, List<TdsDeclarationData>> out = new LinkedHashMap<>();
|
||||
jdbcTemplate.query("""
|
||||
SELECT id, client_id, fy_label, form_type, uin, accepted_on, estimated_income
|
||||
FROM m_tds_declaration
|
||||
WHERE fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", rs -> {
|
||||
final TdsDeclarationData d = new TdsDeclarationData(rs.getLong("id"), rs.getLong("client_id"), rs.getString("fy_label"),
|
||||
rs.getString("form_type"), rs.getString("uin"), readDate(rs, "accepted_on"), rs.getBigDecimal("estimated_income"),
|
||||
false, null, null);
|
||||
out.computeIfAbsent(d.clientId(), k -> new ArrayList<>()).add(d);
|
||||
}, fy);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String readString(final ResultSet rs, final String column) {
|
||||
try {
|
||||
return rs.getString(column);
|
||||
@@ -155,6 +198,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
}
|
||||
}
|
||||
|
||||
private static Long readLong(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final long v = rs.getLong(column);
|
||||
return rs.wasNull() ? null : v;
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate readDate(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final java.sql.Date value = rs.getDate(column);
|
||||
@@ -173,18 +225,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final String pan;
|
||||
private final LocalDate dateOfBirth;
|
||||
private final String branchName;
|
||||
private final String form15Type;
|
||||
private final Map<Long, Account> accounts = new LinkedHashMap<>();
|
||||
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type) {
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, String branchName) {
|
||||
this.clientId = clientId;
|
||||
this.customerId = customerId;
|
||||
this.name = name;
|
||||
this.pan = pan;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.branchName = branchName;
|
||||
this.form15Type = form15Type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,13 +242,21 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final Long id;
|
||||
private final String accountNo;
|
||||
private final String depositTypeCode;
|
||||
private final Long holderId;
|
||||
private final String holderName;
|
||||
private final String accountStatus;
|
||||
private final LocalDate openedOn;
|
||||
private final List<TdsInterestCreditData> credits = new ArrayList<>();
|
||||
private final List<TdsInterestCreditData> deductions = new ArrayList<>();
|
||||
|
||||
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
|
||||
private Account(Long id, String accountNo, String depositTypeCode, Long holderId, String holderName, String accountStatus,
|
||||
LocalDate openedOn) {
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.depositTypeCode = depositTypeCode;
|
||||
this.holderId = holderId;
|
||||
this.holderName = holderName;
|
||||
this.accountStatus = accountStatus;
|
||||
this.openedOn = openedOn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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.tds.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The TDS rules both the deduction and the report must agree on, kept in one place so the passbook
|
||||
* and the register cannot drift apart.
|
||||
*/
|
||||
public final class TdsSql {
|
||||
|
||||
public static final int INTEREST_POSTING = 3;
|
||||
public static final int WITHHOLD_TAX = 18;
|
||||
public static final int FIXED_DEPOSIT = 200;
|
||||
public static final int RECURRING_DEPOSIT = 300;
|
||||
|
||||
/** A well-formed PAN with a recognised holder type - the same test the screens apply. */
|
||||
private static final Pattern PAN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$");
|
||||
private static final String PAN_HOLDER_TYPES = "ABCFGHJLPT";
|
||||
|
||||
private TdsSql() {}
|
||||
|
||||
/**
|
||||
* The client whose PAN carries the deduction for an account, as SQL over the alias given.
|
||||
*
|
||||
* <ul>
|
||||
* <li>A minor's account follows the guardian - the interest is clubbed into the guardian's income.
|
||||
* Minor means under 18 on the first day of the year, so one year has one deductee.</li>
|
||||
* <li>A joint account follows the holder flagged primary - one deposit, one deductee.</li>
|
||||
* <li>Anything else follows the account holder.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Takes one bind parameter: the financial year's first day.
|
||||
*/
|
||||
public static String deducteeOf(final String sa) {
|
||||
return """
|
||||
(CASE
|
||||
WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'MINOR' AND %1$s.guardian_client_id IS NOT NULL
|
||||
AND (%1$s.minor_date_of_birth IS NULL
|
||||
OR %1$s.minor_date_of_birth > CAST(? AS DATE) - INTERVAL '18 years')
|
||||
THEN %1$s.guardian_client_id
|
||||
WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'JOINT'
|
||||
THEN COALESCE((SELECT MIN(j.client_id) FROM m_savings_account_joint_holders j
|
||||
WHERE j.savings_account_id = %1$s.id AND j.is_primary = true), %1$s.client_id)
|
||||
ELSE %1$s.client_id
|
||||
END)""".formatted(sa);
|
||||
}
|
||||
|
||||
public static boolean isWellFormedPan(final String pan) {
|
||||
final String p = pan == null ? "" : pan.trim().toUpperCase();
|
||||
return PAN.matcher(p).matches() && PAN_HOLDER_TYPES.indexOf(p.charAt(3)) >= 0;
|
||||
}
|
||||
|
||||
/** Age reached by the last day of the year: 60 at any point in it makes a senior citizen. */
|
||||
public static boolean isSeniorInFy(final LocalDate dateOfBirth, final LocalDate fyEnd) {
|
||||
return dateOfBirth != null && !dateOfBirth.plusYears(60).isAfter(fyEnd);
|
||||
}
|
||||
|
||||
public static int fyStartYear(final LocalDate date) {
|
||||
return date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
}
|
||||
|
||||
/** '2026-27' for any date from 1 Apr 2026 to 31 Mar 2027. */
|
||||
public static String fyLabel(final int startYear) {
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
|
||||
public static BigDecimal roundToMultiple(final BigDecimal value, final int to) {
|
||||
if (to <= 1) {
|
||||
return value.setScale(0, RoundingMode.HALF_UP);
|
||||
}
|
||||
final BigDecimal step = BigDecimal.valueOf(to);
|
||||
return value.divide(step, 0, RoundingMode.HALF_UP).multiply(step);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:UAT@DB_2025}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -170,6 +170,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=${FINERACT_LOAN_TRANSA
|
||||
fineract.loan.transactionprocessor.heavensfamily.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_HEAVENSFAMILY_ENABLED:true}
|
||||
fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_INTEREST_PRINCIPAL_PENALTIES_FEES_ENABLED:true}
|
||||
fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_PRINCIPAL_INTEREST_PENALTIES_FEES_ENABLED:true}
|
||||
fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_FEES_PENALTIES_INTEREST_PRINCIPAL_ENABLED:true}
|
||||
fineract.loan.transactionprocessor.rbi-india.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_RBI_INDIA_ENABLED:true}
|
||||
fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_ENABLED:true}
|
||||
fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_ENABLED:true}
|
||||
@@ -415,7 +416,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:UAT@DB_2025}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
@@ -615,4 +615,12 @@
|
||||
<include file="parts/0539_map_profit_financial_activities.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0544_dividend_round_off_financial_activity.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0545_ensure_profit_financial_activities_mapped.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0571_create_loan_product_advance_ext.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0572_add_loan_product_advance_ext_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0580_create_loan_rate_register_tables.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0581_enable_loan_rate_register_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0594_loan_rate_register_product_master.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0595_seed_loan_rate_register_gl_accounts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0596_enable_delinquency_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0597_add_loan_product_loans_ext_fields.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -163,6 +163,8 @@
|
||||
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0592_tds_declarations_and_challans.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0593_tds_interest_receipts_and_deductions.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<?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">
|
||||
|
||||
<!-- Satellite table for "Advances A/C" loan-product settings that Fineract's m_product_loan has no
|
||||
home for (security & margin, lien, deposit-linked interest, penal/NPA, renewal, monitoring,
|
||||
cheque book). Keyed 1:1 by loan_product_id; standalone so the LoanProduct entity graph is
|
||||
untouched. Only Advances products get a row. -->
|
||||
<changeSet author="cbsnexora" id="0571_create_loan_product_advance_ext">
|
||||
<createTable tableName="m_product_loan_advance_ext">
|
||||
<column name="loan_product_id" type="BIGINT">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- classification -->
|
||||
<column name="product_type_group" type="VARCHAR(50)"/>
|
||||
<column name="product_type_sub_group" type="VARCHAR(50)"/>
|
||||
<column name="gl_account_id" type="BIGINT"/>
|
||||
<column name="loan_product_category" type="VARCHAR(50)"/>
|
||||
<column name="priority_sector" type="VARCHAR(50)"/>
|
||||
<column name="cibil_account_type" type="VARCHAR(50)"/>
|
||||
<column name="kfs_enabled" type="BOOLEAN"/>
|
||||
<column name="apr_enabled" type="BOOLEAN"/>
|
||||
<!-- facility -->
|
||||
<column name="facility_type" type="VARCHAR(30)"/>
|
||||
<!-- security & margin -->
|
||||
<column name="security_type" type="VARCHAR(50)"/>
|
||||
<column name="charge_type" type="VARCHAR(30)"/>
|
||||
<column name="eligible_amount_basis" type="VARCHAR(50)"/>
|
||||
<column name="margin_pct" type="DECIMAL(9,4)"/>
|
||||
<column name="ltv_pct" type="DECIMAL(9,4)"/>
|
||||
<column name="dp_method" type="VARCHAR(50)"/>
|
||||
<!-- lien -->
|
||||
<column name="lien_required" type="BOOLEAN"/>
|
||||
<column name="lien_marking_stage" type="VARCHAR(30)"/>
|
||||
<column name="lien_certificate_required" type="BOOLEAN"/>
|
||||
<column name="lien_certificate_prefix" type="VARCHAR(20)"/>
|
||||
<column name="print_certificate_on_disbursement" type="BOOLEAN"/>
|
||||
<!-- interest extras -->
|
||||
<column name="interest_basis" type="VARCHAR(30)"/>
|
||||
<column name="spread_pct" type="DECIMAL(9,4)"/>
|
||||
<column name="interest_charged_on" type="VARCHAR(30)"/>
|
||||
<column name="interest_posting_frequency" type="VARCHAR(30)"/>
|
||||
<!-- penal & NPA extras -->
|
||||
<column name="penal_applicable" type="BOOLEAN"/>
|
||||
<column name="penal_rate_pct" type="DECIMAL(9,4)"/>
|
||||
<column name="penal_applied_on" type="VARCHAR(30)"/>
|
||||
<column name="penal_grace_days" type="INT"/>
|
||||
<column name="npa_trigger" type="VARCHAR(30)"/>
|
||||
<column name="interest_after_npa" type="VARCHAR(30)"/>
|
||||
<column name="undebited_interest_gl_id" type="BIGINT"/>
|
||||
<!-- renewal & closure -->
|
||||
<column name="renewal_allowed" type="BOOLEAN"/>
|
||||
<column name="renewal_period_months" type="INT"/>
|
||||
<column name="renewal_grace_days" type="INT"/>
|
||||
<column name="preclosure_allowed" type="VARCHAR(30)"/>
|
||||
<column name="lock_in_months" type="INT"/>
|
||||
<column name="interest_on_preclosure" type="VARCHAR(30)"/>
|
||||
<!-- monitoring -->
|
||||
<column name="revaluation_freq_months" type="INT"/>
|
||||
<column name="insurance_required" type="BOOLEAN"/>
|
||||
<column name="stock_statement_freq_months" type="INT"/>
|
||||
<!-- cheque book -->
|
||||
<column name="cheque_book_enabled" type="BOOLEAN"/>
|
||||
<column name="cheque_type" type="VARCHAR(50)"/>
|
||||
<column name="default_cheque_book_size" type="INT"/>
|
||||
<column name="pps_threshold" type="DECIMAL(19,6)"/>
|
||||
<column name="pps_mandatory_above" type="DECIMAL(19,6)"/>
|
||||
<!-- share (co-op) -->
|
||||
<column name="share_capital_gl_id" type="BIGINT"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="cbsnexora" id="0571_loan_product_advance_ext_product_fk">
|
||||
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="loan_product_id"
|
||||
constraintName="fk_mplae_loan_product" referencedTableName="m_product_loan"
|
||||
referencedColumnNames="id" onDelete="CASCADE"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="cbsnexora" id="0571_loan_product_advance_ext_gl_fks">
|
||||
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="gl_account_id"
|
||||
constraintName="fk_mplae_gl_account" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="undebited_interest_gl_id"
|
||||
constraintName="fk_mplae_undebited_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="share_capital_gl_id"
|
||||
constraintName="fk_mplae_share_capital_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?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">
|
||||
|
||||
<!-- Remaining "Advances A/C" wizard fields so every field the officer fills is persisted:
|
||||
lien rules, eligible deposit products, repayment/appropriation, account behaviour,
|
||||
interest extras, NPA upgrade rules, and the full cheque-book configuration. -->
|
||||
<changeSet author="cbsnexora" id="0572_add_loan_product_advance_ext_fields">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<!-- security & lien extras -->
|
||||
<column name="eligible_deposit_products" type="VARCHAR(500)"/>
|
||||
<column name="lien_tenure_not_exceed_maturity" type="BOOLEAN"/>
|
||||
<column name="lien_auto_release_on_closure" type="BOOLEAN"/>
|
||||
<column name="lien_allow_multiple_deposits" type="BOOLEAN"/>
|
||||
<column name="lien_adjust_from_maturity" type="BOOLEAN"/>
|
||||
<column name="lien_allow_third_party" type="BOOLEAN"/>
|
||||
<column name="lien_allow_renewal_under_lien" type="BOOLEAN"/>
|
||||
<!-- limit & tenure extras -->
|
||||
<column name="repayment_type" type="VARCHAR(40)"/>
|
||||
<column name="appropriation_order" type="VARCHAR(60)"/>
|
||||
<column name="debit_balance_allow" type="BOOLEAN"/>
|
||||
<column name="allow_debit_after_due_date" type="BOOLEAN"/>
|
||||
<column name="branch_level_account_type" type="BOOLEAN"/>
|
||||
<!-- interest extras -->
|
||||
<column name="interest_calc_method" type="VARCHAR(30)"/>
|
||||
<column name="interest_on_credit_balance" type="BOOLEAN"/>
|
||||
<column name="interest_posting_day" type="VARCHAR(30)"/>
|
||||
<!-- NPA upgrade rules -->
|
||||
<column name="npa_upgrade_only_all_arrears" type="BOOLEAN"/>
|
||||
<column name="npa_keep_standard_if_deposit_covers" type="BOOLEAN"/>
|
||||
<!-- cheque book full config -->
|
||||
<column name="cheque_book_sizes" type="VARCHAR(50)"/>
|
||||
<column name="cheque_max_books_outstanding" type="INT"/>
|
||||
<column name="cheque_reissue_when_leaves" type="INT"/>
|
||||
<column name="cheque_first_book_free" type="BOOLEAN"/>
|
||||
<column name="cheque_free_books_per_year" type="INT"/>
|
||||
<column name="cheque_name_printed" type="VARCHAR(50)"/>
|
||||
<column name="cheque_print_description" type="VARCHAR(100)"/>
|
||||
<column name="cheque_stop_payment_allowed" type="BOOLEAN"/>
|
||||
<column name="cheque_maintain_stock_register" type="BOOLEAN"/>
|
||||
<column name="cheque_allow_withdrawal_slip" type="BOOLEAN"/>
|
||||
<column name="cheque_block_npa" type="BOOLEAN"/>
|
||||
<column name="cheque_block_limit_expired" type="BOOLEAN"/>
|
||||
<column name="cheque_block_over_dp" type="BOOLEAN"/>
|
||||
<column name="cheque_block_frozen" type="BOOLEAN"/>
|
||||
<column name="cheque_block_lien_released" type="BOOLEAN"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Central Loan Interest Rate Register — one approved rate chart (34 products × 10 amount
|
||||
slabs) + CIBIL/credit-score adjustment bands. Mirrors the "CBS Base Interest Rate" (FD)
|
||||
versioning model: each save creates a new effective row and deactivates the previous one.
|
||||
Maker-checker gated through Fineract's standard command-source mechanism (see 0581).
|
||||
-->
|
||||
|
||||
<!-- 1. Version header: one row per effective version. is_active flags the currently in-force one. -->
|
||||
<changeSet id="0580a_create_loan_rate_register_effective" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_loan_rate_register_effective"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_loan_rate_register_effective">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="name" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="effective_from_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="effective_to_date" type="DATE"/>
|
||||
<column name="currency_code" type="VARCHAR(3)" defaultValue="INR">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_active" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Fallback behaviour when a loan's CIBIL score is unavailable: pointer into the CIBIL
|
||||
slab list for this version (1-indexed, matches UI). -->
|
||||
<column name="fallback_no_cibil_slab" type="INT" defaultValueNumeric="2">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Fallback when CIBIL < min configured slab: 'use-min-slab' or 'block'. -->
|
||||
<column name="fallback_below_min" type="VARCHAR(20)" defaultValue="block">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="note" type="VARCHAR(500)"/>
|
||||
<column name="created_by_id" type="BIGINT"/>
|
||||
<column name="created_date" type="DATETIME"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<!-- 2. Rate chart cells: one row per {version × product × amount-slab}. Product name and slab
|
||||
label are denormalised for query convenience + display. Cell value is either a plain rate,
|
||||
a formula string ("1% + FD"), or N.A. (both value and formula null, is_na=true). -->
|
||||
<changeSet id="0580b_create_loan_rate_register_slab_history" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_loan_rate_register_slab_history"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_loan_rate_register_slab_history">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="effective_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Product dimension -->
|
||||
<column name="product_sr_no" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="product_name" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Amount-slab dimension -->
|
||||
<column name="slab_order" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="slab_label" type="VARCHAR(60)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="amount_upto" type="DECIMAL(19,2)"/>
|
||||
<!-- Cell value: at most ONE of rate_value / rate_formula is non-null; both null + is_na=true means slab not offered. -->
|
||||
<column name="rate_value" type="DECIMAL(9,4)"/>
|
||||
<column name="rate_formula" type="VARCHAR(50)"/>
|
||||
<column name="is_formula" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_na" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_date" type="DATETIME"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<!-- 3. CIBIL/credit-score adjustment bands: 5 rows per version (typical), layered on top of the
|
||||
chart rate at loan sanction. -->
|
||||
<changeSet id="0580c_create_loan_rate_register_cibil_slab" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_loan_rate_register_cibil_slab"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_loan_rate_register_cibil_slab">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="effective_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="score_from" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="score_to" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="tier" type="VARCHAR(30)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="slab_type" type="VARCHAR(20)" defaultValue="Flat">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="adjustment_pct" type="DECIMAL(5,2)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="display_order" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_date" type="DATETIME"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<!-- 4. Foreign keys — effective_id in child tables cascades on delete so retiring a version
|
||||
cleans up its slabs and CIBIL rows atomically. -->
|
||||
<changeSet id="0580d_loan_rate_register_fks" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<addForeignKeyConstraint baseTableName="m_loan_rate_register_slab_history"
|
||||
baseColumnNames="effective_id"
|
||||
constraintName="fk_mlrrsh_effective"
|
||||
referencedTableName="m_loan_rate_register_effective"
|
||||
referencedColumnNames="id"
|
||||
onDelete="CASCADE"/>
|
||||
<addForeignKeyConstraint baseTableName="m_loan_rate_register_cibil_slab"
|
||||
baseColumnNames="effective_id"
|
||||
constraintName="fk_mlrrcs_effective"
|
||||
referencedTableName="m_loan_rate_register_effective"
|
||||
referencedColumnNames="id"
|
||||
onDelete="CASCADE"/>
|
||||
<addForeignKeyConstraint baseTableName="m_loan_rate_register_effective"
|
||||
baseColumnNames="created_by_id"
|
||||
constraintName="fk_mlrre_created_by"
|
||||
referencedTableName="m_appuser"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<!-- 5. Indexes for the hot read paths: (a) "give me the current active" (b) "give me a version's
|
||||
rate for a product+slab" (c) "give me a version's CIBIL slabs ordered". -->
|
||||
<changeSet id="0580e_loan_rate_register_indexes" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<createIndex indexName="idx_mlrre_active" tableName="m_loan_rate_register_effective">
|
||||
<column name="is_active"/>
|
||||
<column name="effective_from_date"/>
|
||||
</createIndex>
|
||||
<createIndex indexName="idx_mlrrsh_lookup" tableName="m_loan_rate_register_slab_history">
|
||||
<column name="effective_id"/>
|
||||
<column name="product_sr_no"/>
|
||||
<column name="slab_order"/>
|
||||
</createIndex>
|
||||
<createIndex indexName="idx_mlrrcs_order" tableName="m_loan_rate_register_cibil_slab">
|
||||
<column name="effective_id"/>
|
||||
<column name="display_order"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Enable maker-checker on the Loan Interest Rate Register. Mirrors 0470 for the FD side.
|
||||
Two permissions: the maker's gated with can_maker_checker=true so submits land in
|
||||
m_portfolio_command_source as PENDING; the checker approves from the standard Checker Inbox
|
||||
via /makercheckers/{id}?command=approve, which fires CreateLoanBaseInterestRateCommandHandler.
|
||||
|
||||
Permission codes use the LOANBASEINTERESTRATE entity (no "CBS" prefix) to match the clean
|
||||
naming convention requested for the Loan module.
|
||||
-->
|
||||
|
||||
<changeSet id="0581a_loan_rate_register_maker_checker_permissions" author="cbsnexora" runOnChange="false">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
-- Rename any previously-seeded CBS-prefixed rows (safe no-op if a fresh DB).
|
||||
UPDATE m_permission
|
||||
SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE'
|
||||
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE';
|
||||
|
||||
UPDATE m_permission
|
||||
SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE'
|
||||
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE', 'LOANBASEINTERESTRATE', 'CREATE', true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE_CHECKER', 'LOANBASEINTERESTRATE', 'CREATE_CHECKER', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE_CHECKER');
|
||||
|
||||
UPDATE m_permission
|
||||
SET can_maker_checker = TRUE
|
||||
WHERE entity_name = 'LOANBASEINTERESTRATE'
|
||||
AND action_name = 'CREATE';
|
||||
|
||||
UPDATE m_permission
|
||||
SET action_name = 'CREATE_CHECKER'
|
||||
WHERE entity_name = 'LOANBASEINTERESTRATE'
|
||||
AND code = 'CREATE_LOANBASEINTERESTRATE_CHECKER';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Separate changeset to force the CBS→LOAN rename on DBs that already applied 0581a with the
|
||||
old names. Liquibase won't re-run an already-applied changeset, so the UPDATE statements
|
||||
inside 0581a don't fire a second time; this new id runs once per DB on next startup. -->
|
||||
<changeSet id="0581c_rename_cbs_prefix_to_loanbaseinterestrate" author="cbsnexora" runOnChange="false">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
UPDATE m_permission
|
||||
SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE'
|
||||
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE';
|
||||
|
||||
UPDATE m_permission
|
||||
SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE'
|
||||
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
|
||||
|
||||
-- Rename any pending command-source rows still carrying the old entity string so
|
||||
-- the handler picks them up after the restart.
|
||||
UPDATE m_portfolio_command_source
|
||||
SET entity_name = 'LOANBASEINTERESTRATE'
|
||||
WHERE entity_name = 'CBSLOANBASEINTERESTRATE';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0581b_grant_loan_rate_register_permissions_to_roles" author="cbsnexora" runOnChange="false">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
-- Super user: both maker and checker
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE r.name = 'Super user'
|
||||
AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
);
|
||||
|
||||
-- bankadmin user's role(s): both maker and checker
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT ur.role_id, p.id
|
||||
FROM m_appuser u
|
||||
JOIN m_appuser_role ur ON ur.appuser_id = u.id
|
||||
CROSS JOIN m_permission p
|
||||
WHERE u.username = 'bankadmin'
|
||||
AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
|
||||
);
|
||||
|
||||
-- Clerk: maker only (separation of duties — clerks submit, higher authority approves)
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE LOWER(r.name) = 'clerk'
|
||||
AND p.code = 'CREATE_LOANBASEINTERESTRATE'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
|
||||
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
|
||||
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
|
||||
dev database - none of that was ever captured by a migration, so a fresh/other environment
|
||||
restored from this changelog history would come up with every one of them still at its
|
||||
originally-seeded false value. Every code here was independently confirmed, via read-only
|
||||
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
|
||||
= false and currently true in the running dev DB with no existing changeset already covering
|
||||
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
|
||||
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
|
||||
|
||||
This changeset only ever moves can_maker_checker from false to true for these exact codes -
|
||||
entity_name, action_name, grouping and every other column are left exactly as their origin
|
||||
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
|
||||
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
|
||||
touched: those already exist for every code below (added earlier by 0583/0586, or by base
|
||||
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
|
||||
|
||||
Grouped by the screen each permission belongs to:
|
||||
- Currency Configuration: UPDATE_CURRENCY
|
||||
- Manage Funds: CREATE_FUND
|
||||
- Working Days: UPDATE_WORKINGDAYS
|
||||
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
|
||||
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
|
||||
- Branch Profile: UPDATE_BRANCHPROFILE
|
||||
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
|
||||
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
|
||||
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
|
||||
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
|
||||
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
|
||||
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
|
||||
- Manage User Agreement: CREATE_USERAGREEMENT
|
||||
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
|
||||
|
||||
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
|
||||
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
|
||||
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
|
||||
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
|
||||
Survey permissions (confirmed still false - never actually toggled on)
|
||||
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
|
||||
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
|
||||
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
|
||||
Organization or Admin -> System - out of scope for this change and left as pre-existing
|
||||
drift for a separate decision. -->
|
||||
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
|
||||
<update tableName="m_permission">
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
<where>code IN (
|
||||
'UPDATE_CURRENCY',
|
||||
'CREATE_FUND',
|
||||
'UPDATE_WORKINGDAYS',
|
||||
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
|
||||
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
|
||||
'UPDATE_BRANCHPROFILE',
|
||||
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
|
||||
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
|
||||
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
|
||||
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
|
||||
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
|
||||
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
|
||||
'CREATE_USERAGREEMENT',
|
||||
'UPDATE_BATCH_BUSINESS_STEP'
|
||||
)</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Same pattern as 0588_persist_manually_enabled_maker_checker_permissions: converts a
|
||||
manually-applied Configure Maker Checker Tasks change into a repeatable migration, this time
|
||||
for the Teller/Cashier module. Found by diffing can_maker_checker across every permission code
|
||||
common to fineract_lincpay and a reference environment (fineract_default) - these 4 were true
|
||||
in the reference but still seeded false here, confirming they were toggled on manually there
|
||||
and never captured for any other environment (including this one and UAT).
|
||||
|
||||
Grouped by the screen each permission belongs to (Organization -> Manage Tellers -> allocate/
|
||||
settle cash for a cashier):
|
||||
ALLOCATECASHIER_TELLER, ALLOCATECASHTOCASHIER_TELLER,
|
||||
SETTLECASHFROMCASHIER_TELLER, UPDATECASHIERALLOCATION_TELLER
|
||||
|
||||
No _CHECKER row is touched: those already exist for every code below (seeded by base
|
||||
Fineract), and can_maker_checker is not a column on a _CHECKER row. -->
|
||||
<changeSet id="0592_enable_teller_cashier_maker_checker" author="cbs-nitesh">
|
||||
<update tableName="m_permission">
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
<where>code IN (
|
||||
'ALLOCATECASHIER_TELLER',
|
||||
'ALLOCATECASHTOCASHIER_TELLER',
|
||||
'SETTLECASHFROMCASHIER_TELLER',
|
||||
'UPDATECASHIERALLOCATION_TELLER'
|
||||
)</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
<?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.9.xsd">
|
||||
|
||||
<!--
|
||||
The two records TDS on deposit interest was missing.
|
||||
|
||||
m_tds_declaration - a 15G / 15H / Form 121 declaration, one per customer per financial year.
|
||||
The KYC record only ever carried the form type and a year, so a declaration given in
|
||||
September shielded interest back to April, and nothing marked a new year as needing a new
|
||||
form. A declaration operates from the day the branch accepts it, for that year only, so both
|
||||
the year and the acceptance date are kept here.
|
||||
|
||||
m_tds_challan - tax actually remitted to the Government. The remittance report used to make
|
||||
challan numbers, BSR codes and deposit dates up; every row here is one the bank entered,
|
||||
posted against the TDS payable account.
|
||||
|
||||
Constraints sit inside createTable: a separate tableExists-guarded changeSet would see the
|
||||
table already present and skip them.
|
||||
-->
|
||||
|
||||
<changeSet author="fineract" id="0592-1-create-tds-declaration">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_tds_declaration"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_tds_declaration">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_declaration_client"
|
||||
referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- '2026-27'. -->
|
||||
<column name="fy_label" type="VARCHAR(7)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- 15G, 15H or 121. -->
|
||||
<column name="form_type" type="VARCHAR(5)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="uin" type="VARCHAR(30)"/>
|
||||
<!-- The day the branch accepted it. Interest credited before this date is not shielded. -->
|
||||
<column name="accepted_on" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="estimated_income" type="DECIMAL(19,6)"/>
|
||||
<column name="is_revoked" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="revoked_on" type="DATE"/>
|
||||
<column name="revoke_reason" type="VARCHAR(500)"/>
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
|
||||
<column name="revoked_by" type="BIGINT"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_tds_declaration" indexName="idx_tds_declaration_client_fy">
|
||||
<column name="client_id"/>
|
||||
<column name="fy_label"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0592-2-create-tds-challan">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_tds_challan"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_tds_challan">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="fy_label" type="VARCHAR(7)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Month the tax was deducted in, '2026-06'. The due date follows from it. -->
|
||||
<column name="deduction_month" type="VARCHAR(7)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="challan_no" type="VARCHAR(30)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="bsr_code" type="VARCHAR(10)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="deposited_on" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Bank or cash account the tax was paid out of: Dr TDS payable / Cr this account. -->
|
||||
<column name="paid_from_gl_account_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_challan_paid_from"
|
||||
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<column name="office_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_challan_office"
|
||||
referencedTableName="m_office" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- acc_gl_journal_entry.transaction_id of the remittance entry. -->
|
||||
<column name="journal_transaction_id" type="VARCHAR(50)"/>
|
||||
<column name="remarks" type="VARCHAR(500)"/>
|
||||
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_tds_challan" indexName="idx_tds_challan_fy">
|
||||
<column name="fy_label"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0592-3-tds-declaration-challan-permissions">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_permission"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_permission
|
||||
WHERE code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION', 'CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN')
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="CREATE_TDSDECLARATION"/>
|
||||
<column name="entity_name" value="TDSDECLARATION"/>
|
||||
<column name="action_name" value="CREATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="UPDATE_TDSDECLARATION"/>
|
||||
<column name="entity_name" value="TDSDECLARATION"/>
|
||||
<column name="action_name" value="UPDATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="CREATE_TDSCHALLAN"/>
|
||||
<column name="entity_name" value="TDSCHALLAN"/>
|
||||
<column name="action_name" value="CREATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="UPDATE_TDSCHALLAN"/>
|
||||
<column name="entity_name" value="TDSCHALLAN"/>
|
||||
<column name="action_name" value="UPDATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0592-4-grant-tds-declaration-challan-permissions">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_role_permission"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_role_permission rp
|
||||
JOIN m_permission p ON p.id = rp.permission_id
|
||||
WHERE p.code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION', 'CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN')
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<!-- Declarations are taken at the counter, so every role records them. Challans are a Head
|
||||
Office remittance, so only the roles that hold the TDS configuration post them. -->
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE p.code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION');
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE p.code IN ('CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN')
|
||||
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0592-5-carry-over-kyc-declarations">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_tds_declaration"/>
|
||||
</preConditions>
|
||||
<!-- Only KYC rows that were actually switched on and name a year. The acceptance date was never
|
||||
recorded, so the year's first day stands in, which is what the screens assumed until now. -->
|
||||
<sql>
|
||||
INSERT INTO m_tds_declaration (client_id, fy_label, form_type, accepted_on, is_revoked, created_on)
|
||||
SELECT k.client_id, k.form15_fy, UPPER(k.form15_type),
|
||||
TO_DATE(SUBSTRING(k.form15_fy FROM 1 FOR 4) || '-04-01', 'YYYY-MM-DD'), false, now()
|
||||
FROM m_client_kyc k
|
||||
WHERE k.form15_applicable = true
|
||||
AND k.form15_fy ~ '^[0-9]{4}-[0-9]{2}$'
|
||||
AND UPPER(k.form15_type) IN ('15G', '15H', '121')
|
||||
AND COALESCE(k.is_deleted, false) = false;
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
<?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.9.xsd">
|
||||
|
||||
<!--
|
||||
TDS on FD/RD interest is tracked and deducted where the interest is received: the customer's
|
||||
savings account. The FD and RD accounts themselves are left alone.
|
||||
|
||||
m_tds_interest_receipt - one row per credit into a savings account that carried FD/RD interest:
|
||||
the periodic interest sweep in full, or the interest part of a maturity / closure payout. This
|
||||
is the running total the threshold is tested against. A savings credit is otherwise just a
|
||||
deposit, so without this record there is no telling interest from the customer's own money.
|
||||
|
||||
m_tds_deduction - one row per TDS debit on a savings account, tied to the deductee, so the
|
||||
tax already taken this year is known per customer.
|
||||
|
||||
A row only counts while its savings transaction stands - reversing the transaction takes it out.
|
||||
-->
|
||||
|
||||
<changeSet author="fineract" id="0593-1-create-tds-interest-receipt">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_tds_interest_receipt"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_tds_interest_receipt">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- Whose PAN the interest is taxed against: the FD/RD's holder, guardian or primary joint holder. -->
|
||||
<column name="deductee_client_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_receipt_client"
|
||||
referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- The savings account the interest was credited to. -->
|
||||
<column name="savings_account_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_receipt_savings"
|
||||
referencedTableName="m_savings_account" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<column name="savings_transaction_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_receipt_txn"
|
||||
referencedTableName="m_savings_account_transaction" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- The FD or RD the interest came from. -->
|
||||
<column name="source_account_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_receipt_source"
|
||||
referencedTableName="m_savings_account" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- INTEREST: a periodic interest sweep. PAYOUT: the interest part of a maturity or closure payout. -->
|
||||
<column name="kind" type="VARCHAR(10)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="fy_label" type="VARCHAR(7)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="received_on" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="interest_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_tds_interest_receipt" indexName="idx_tds_receipt_deductee_fy">
|
||||
<column name="deductee_client_id"/>
|
||||
<column name="fy_label"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_tds_interest_receipt" indexName="idx_tds_receipt_source">
|
||||
<column name="source_account_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0593-2-create-tds-deduction">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_tds_deduction"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_tds_deduction">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="deductee_client_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_deduction_client"
|
||||
referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<column name="savings_account_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_deduction_savings"
|
||||
referencedTableName="m_savings_account" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- The withhold-tax debit on the savings account. -->
|
||||
<column name="savings_transaction_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_deduction_txn"
|
||||
referencedTableName="m_savings_account_transaction" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- The FD or RD whose interest triggered it. -->
|
||||
<column name="source_account_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_deduction_source"
|
||||
referencedTableName="m_savings_account" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<column name="receipt_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_tds_deduction_receipt"
|
||||
referencedTableName="m_tds_interest_receipt" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<column name="fy_label" type="VARCHAR(7)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="deducted_on" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="rate_percentage" type="DECIMAL(9,4)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Interest the year's liability was worked out on, at the time. -->
|
||||
<column name="interest_base" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_tds_deduction" indexName="idx_tds_deduction_deductee_fy">
|
||||
<column name="deductee_client_id"/>
|
||||
<column name="fy_label"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Master list of "particulars" (loan types) that appear as rows in the Loan Interest Rate
|
||||
Register chart. The GL account belongs to the particular, not to a version, so it is kept
|
||||
here and never has to be re-selected when a new rate version is created.
|
||||
|
||||
Seeded with the ~16 loan types that almost every bank offers. Bank-specific particulars are
|
||||
added later through Loan Interest Rate Register > Edit (new version) > Add product, which
|
||||
requires a GL. gl_account_id is NULL for the seeded rows until the bank maps them.
|
||||
-->
|
||||
|
||||
<changeSet id="0594a_create_loan_rate_register_product" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_loan_rate_register_product"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_loan_rate_register_product">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="sr_no" type="INT">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_mlrrp_sr_no"/>
|
||||
</column>
|
||||
<column name="name" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="gl_account_id" type="BIGINT"/>
|
||||
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_date" type="DATETIME"/>
|
||||
</createTable>
|
||||
<addForeignKeyConstraint baseTableName="m_loan_rate_register_product"
|
||||
baseColumnNames="gl_account_id"
|
||||
constraintName="fk_mlrrp_gl_account"
|
||||
referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<!-- Link chart cells to the particular by id. product_sr_no / product_name stay on the row as the
|
||||
denormalised label of that version; product_id is the stable link to the master (and its GL).
|
||||
Nullable so rows written before this changeset survive; backfilled by sr_no below. -->
|
||||
<changeSet id="0594c_loan_rate_register_slab_history_product_id" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_loan_rate_register_slab_history" columnName="product_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_loan_rate_register_slab_history">
|
||||
<column name="product_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
<addForeignKeyConstraint baseTableName="m_loan_rate_register_slab_history"
|
||||
baseColumnNames="product_id"
|
||||
constraintName="fk_mlrrsh_product"
|
||||
referencedTableName="m_loan_rate_register_product"
|
||||
referencedColumnNames="id"/>
|
||||
<createIndex indexName="idx_mlrrsh_product" tableName="m_loan_rate_register_slab_history">
|
||||
<column name="product_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0594d_backfill_loan_rate_register_slab_history_product_id" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
UPDATE m_loan_rate_register_slab_history
|
||||
SET product_id = (SELECT p.id FROM m_loan_rate_register_product p
|
||||
WHERE p.sr_no = m_loan_rate_register_slab_history.product_sr_no)
|
||||
WHERE product_id IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0594b_seed_loan_rate_register_default_products" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_loan_rate_register_product</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="1"/>
|
||||
<column name="name" value="Housing Loan (Purchase/Construction)"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="2"/>
|
||||
<column name="name" value="Home Repair/Renovation"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="3"/>
|
||||
<column name="name" value="Personal Loan (Unsecured)"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="4"/>
|
||||
<column name="name" value="Personal Loan (Secured/Property)"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="5"/>
|
||||
<column name="name" value="Two Wheeler Loan"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="6"/>
|
||||
<column name="name" value="Car Loan (New)"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="7"/>
|
||||
<column name="name" value="Used Car/Vehicle Loan"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="8"/>
|
||||
<column name="name" value="Commercial Vehicle Loan"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="9"/>
|
||||
<column name="name" value="Gold Loan"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="10"/>
|
||||
<column name="name" value="Business Term Loan"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="11"/>
|
||||
<column name="name" value="Machinery & Equipment Loan"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="12"/>
|
||||
<column name="name" value="Cash Credit"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="13"/>
|
||||
<column name="name" value="Overdraft"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="14"/>
|
||||
<column name="name" value="Agriculture Loan"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="15"/>
|
||||
<column name="name" value="Loan against FDR"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
<insert tableName="m_loan_rate_register_product">
|
||||
<column name="sr_no" valueNumeric="16"/>
|
||||
<column name="name" value="Loan against RD"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_date" valueComputed="NOW()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Loan portfolio (asset) GL accounts for the Loan Interest Rate Register particulars that had no
|
||||
GL in the standardized chart of accounts (0503). Existing GLs are reused for the rest:
|
||||
LOAN AGAINST DEPOSITE 100301, CASH CREDIT 100303, LOAN AGAINST PROPERTY 100401,
|
||||
PERSONAL LOAN 100402, HOUSE LOAN 100501.
|
||||
|
||||
Each row is inserted under its existing header (SHORT/MEDIUM/LONG TERM LOAN) and only when that
|
||||
header exists, so tenants with a different chart of accounts are left untouched. Codes follow the
|
||||
0503 scheme: classification(1) + group(2) + sequence(3). Idempotent on gl_code.
|
||||
-->
|
||||
<changeSet id="0595_seed_loan_rate_register_gl_accounts" author="cbsnexora">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
-- SHORT TERM LOAN (100300)
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'OVERDRAFT', '100304', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100304');
|
||||
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'GOLD LOAN', '100305', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100305');
|
||||
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'AGRICULTURE LOAN', '100306', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100306');
|
||||
|
||||
-- MEDIUM TERM LOAN (100400)
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'TWO WHEELER LOAN', '100403', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100403');
|
||||
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'CAR LOAN (NEW)', '100404', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100404');
|
||||
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'USED CAR / VEHICLE LOAN', '100405', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100405');
|
||||
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'COMMERCIAL VEHICLE LOAN', '100406', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100406');
|
||||
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'BUSINESS TERM LOAN', '100407', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100407');
|
||||
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'MACHINERY & EQUIPMENT LOAN', '100408', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100408');
|
||||
|
||||
-- LONG TERM LOAN (100500)
|
||||
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
|
||||
SELECT 'HOME REPAIR / RENOVATION LOAN', '100502', false, true, 1, 1, p.id FROM acc_gl_account p
|
||||
WHERE p.gl_code = '100500' AND p.name = 'LONG TERM LOAN'
|
||||
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100502');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Maker-checker on Delinquency Ranges and Buckets (the SMA / NPA classification master).
|
||||
|
||||
A bucket or range feeds the SMA / NPA class of every loan product linked to it, so create, update
|
||||
and delete must not take effect on one person's click. The six commands (CREATE / UPDATE / DELETE on
|
||||
DELINQUENCY_RANGE and DELINQUENCY_BUCKET) are gated with can_maker_checker = true, so each lands in
|
||||
m_portfolio_command_source as PENDING and is applied only when a checker approves it from the
|
||||
Checker Inbox. Each gets a matching *_CHECKER permission, which approving requires.
|
||||
|
||||
Roles: create / update / delete (maker) and the checker permissions go to Super user and the
|
||||
bankadmin user's roles only. UPDATE and DELETE are taken away from every other role. CREATE is left
|
||||
as it is for roles that already hold it. Widen or narrow this later from Admin > Roles.
|
||||
-->
|
||||
|
||||
<changeSet id="0596a_delinquency_maker_checker_permissions" author="cbsnexora" runOnChange="false">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
UPDATE m_permission
|
||||
SET can_maker_checker = TRUE
|
||||
WHERE code IN ('CREATE_DELINQUENCY_RANGE', 'UPDATE_DELINQUENCY_RANGE', 'DELETE_DELINQUENCY_RANGE',
|
||||
'CREATE_DELINQUENCY_BUCKET', 'UPDATE_DELINQUENCY_BUCKET', 'DELETE_DELINQUENCY_BUCKET');
|
||||
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'organisation', v.code, v.entity_name, v.action_name, false
|
||||
FROM (
|
||||
SELECT 'CREATE_DELINQUENCY_RANGE_CHECKER' AS code, 'DELINQUENCY_RANGE' AS entity_name, 'CREATE_CHECKER' AS action_name
|
||||
UNION ALL SELECT 'UPDATE_DELINQUENCY_RANGE_CHECKER', 'DELINQUENCY_RANGE', 'UPDATE_CHECKER'
|
||||
UNION ALL SELECT 'DELETE_DELINQUENCY_RANGE_CHECKER', 'DELINQUENCY_RANGE', 'DELETE_CHECKER'
|
||||
UNION ALL SELECT 'CREATE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'CREATE_CHECKER'
|
||||
UNION ALL SELECT 'UPDATE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'UPDATE_CHECKER'
|
||||
UNION ALL SELECT 'DELETE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'DELETE_CHECKER'
|
||||
) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission p WHERE p.code = v.code);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0596b_delinquency_permissions_to_admin_roles_only" author="cbsnexora" runOnChange="false">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
-- Super user: maker + checker for all six commands.
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE r.name = 'Super user'
|
||||
AND p.entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
|
||||
AND p.action_name IN ('CREATE', 'UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
);
|
||||
|
||||
-- bankadmin user's role(s): maker + checker for all six commands.
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT DISTINCT ur.role_id, p.id
|
||||
FROM m_appuser u
|
||||
JOIN m_appuser_role ur ON ur.appuser_id = u.id
|
||||
CROSS JOIN m_permission p
|
||||
WHERE u.username = 'bankadmin'
|
||||
AND p.entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
|
||||
AND p.action_name IN ('CREATE', 'UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
|
||||
);
|
||||
|
||||
-- Every other role loses UPDATE / DELETE on ranges and buckets (and holds no checker permission).
|
||||
DELETE FROM m_role_permission
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM m_permission
|
||||
WHERE entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
|
||||
AND action_name IN ('UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
|
||||
)
|
||||
AND role_id NOT IN (
|
||||
SELECT id FROM m_role WHERE name = 'Super user'
|
||||
UNION
|
||||
SELECT ur.role_id
|
||||
FROM m_appuser u
|
||||
JOIN m_appuser_role ur ON ur.appuser_id = u.id
|
||||
WHERE u.username = 'bankadmin'
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?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">
|
||||
|
||||
<!-- Loans A/C (EMI) wizard fields that Fineract's loan-product schema has no home for: collateral and
|
||||
guarantor rules, borrower eligibility, disbursement, repayment mode, rate source / floating reset,
|
||||
part-prepayment and foreclosure. Stored on the same per-product satellite as the Advances fields.
|
||||
The columns are prefixed loan_ so they never clash with an Advances column. -->
|
||||
<changeSet author="cbsnexora" id="0597_add_loan_product_loans_ext_fields">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<column name="loan_security_mandatory" type="VARCHAR(100)"/>
|
||||
<column name="loan_guarantors_required" type="INT"/>
|
||||
<column name="loan_co_applicant" type="VARCHAR(100)"/>
|
||||
<column name="loan_third_party_security" type="VARCHAR(100)"/>
|
||||
<column name="loan_legal_opinion_required" type="BOOLEAN"/>
|
||||
<column name="loan_property_insurance_required" type="BOOLEAN"/>
|
||||
<column name="loan_register_charge" type="BOOLEAN"/>
|
||||
<column name="loan_release_security_on_closure" type="BOOLEAN"/>
|
||||
<column name="loan_allow_multiple_securities" type="BOOLEAN"/>
|
||||
<column name="loan_allow_third_party_deposit" type="BOOLEAN"/>
|
||||
<column name="loan_min_age" type="INT"/>
|
||||
<column name="loan_max_age_at_maturity" type="INT"/>
|
||||
<column name="loan_min_cibil" type="INT"/>
|
||||
<column name="loan_min_monthly_income" type="DECIMAL(19,6)"/>
|
||||
<column name="loan_max_foir" type="DECIMAL(19,6)"/>
|
||||
<column name="loan_max_loans_per_customer" type="INT"/>
|
||||
<column name="loan_disbursement_type" type="VARCHAR(100)"/>
|
||||
<column name="loan_disbursement_mode" type="VARCHAR(100)"/>
|
||||
<column name="loan_undisbursed_interest" type="VARCHAR(100)"/>
|
||||
<column name="loan_pre_emi_interest" type="BOOLEAN"/>
|
||||
<column name="loan_emi_due_day" type="VARCHAR(100)"/>
|
||||
<column name="loan_holiday_rule" type="VARCHAR(100)"/>
|
||||
<column name="loan_moratorium_months" type="INT"/>
|
||||
<column name="loan_moratorium_interest" type="VARCHAR(100)"/>
|
||||
<column name="loan_variable_installments" type="BOOLEAN"/>
|
||||
<column name="loan_repayment_mode" type="VARCHAR(100)"/>
|
||||
<column name="loan_linked_account_type" type="VARCHAR(100)"/>
|
||||
<column name="loan_pdc_collection" type="VARCHAR(100)"/>
|
||||
<column name="loan_mandate_required" type="BOOLEAN"/>
|
||||
<column name="loan_advance_emi" type="VARCHAR(100)"/>
|
||||
<column name="loan_rate_type" type="VARCHAR(100)"/>
|
||||
<column name="loan_register_particular" type="VARCHAR(100)"/>
|
||||
<column name="loan_apply_cibil_adjustment" type="BOOLEAN"/>
|
||||
<column name="loan_rate_resolved_at" type="VARCHAR(100)"/>
|
||||
<column name="loan_calc_period" type="VARCHAR(100)"/>
|
||||
<column name="loan_interest_starts_from" type="VARCHAR(100)"/>
|
||||
<column name="loan_compounding" type="VARCHAR(100)"/>
|
||||
<column name="loan_benchmark" type="VARCHAR(100)"/>
|
||||
<column name="loan_reset_frequency" type="VARCHAR(100)"/>
|
||||
<column name="loan_reset_change" type="VARCHAR(100)"/>
|
||||
<column name="loan_part_prepayment_allowed" type="BOOLEAN"/>
|
||||
<column name="loan_min_part_payment" type="DECIMAL(19,6)"/>
|
||||
<column name="loan_allowed_on" type="VARCHAR(100)"/>
|
||||
<column name="loan_part_payments_per_year" type="VARCHAR(100)"/>
|
||||
<column name="loan_part_prepayment_mode" type="VARCHAR(100)"/>
|
||||
<column name="loan_foreclosure_floating_individual" type="DECIMAL(19,6)"/>
|
||||
<column name="loan_foreclosure_fixed_individual" type="DECIMAL(19,6)"/>
|
||||
<column name="loan_foreclosure_non_individual" type="DECIMAL(19,6)"/>
|
||||
<column name="loan_foreclosure_takeover" type="DECIMAL(19,6)"/>
|
||||
<column name="loan_foreclosure_quote_valid_days" type="INT"/>
|
||||
<column name="loan_auto_close_on_zero_balance" type="BOOLEAN"/>
|
||||
<column name="loan_noc_letter" type="VARCHAR(100)"/>
|
||||
<column name="loan_restructuring" type="VARCHAR(100)"/>
|
||||
<column name="loan_release_collateral_on_closure" type="BOOLEAN"/>
|
||||
<column name="loan_top_up_allowed" type="BOOLEAN"/>
|
||||
<column name="loan_balance_transfer_out_allowed" type="BOOLEAN"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+6
-12
@@ -116,11 +116,7 @@ public class CommandSourceServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
|
||||
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
|
||||
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
|
||||
// replaces the old bypass this test used to assert.
|
||||
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
|
||||
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
||||
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
||||
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
||||
@@ -129,8 +125,6 @@ public class CommandSourceServiceTest {
|
||||
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
||||
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
||||
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
||||
Mockito.when(commandSource.getId()).thenReturn(300L);
|
||||
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
|
||||
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
||||
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
||||
|
||||
@@ -138,12 +132,12 @@ public class CommandSourceServiceTest {
|
||||
.withCommandId(1L).build();
|
||||
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
||||
|
||||
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
|
||||
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
|
||||
});
|
||||
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
|
||||
commandSource, superUser, false);
|
||||
|
||||
Mockito.verify(commandSource).markAsAwaitingApproval();
|
||||
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
|
||||
Assertions.assertNotNull(result);
|
||||
Mockito.verify(commandSource).markAsChecked(superUser);
|
||||
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -87,6 +87,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=true
|
||||
fineract.loan.transactionprocessor.heavensfamily.enabled=true
|
||||
fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=true
|
||||
fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=true
|
||||
fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=true
|
||||
fineract.loan.transactionprocessor.rbi-india.enabled=true
|
||||
fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=true
|
||||
fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=true
|
||||
|
||||
+23
@@ -795,6 +795,29 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deducts TDS from this savings account on FD/RD interest that has just been credited to it.
|
||||
*
|
||||
* <p>
|
||||
* Whether anything is due, and how much, is decided outside the entity - the threshold is tested on
|
||||
* the customer's interest across the bank and a declaration on record switches it off. This only
|
||||
* posts the withhold-tax debit and brings the running balances and summary up to date, as any other
|
||||
* transaction on the account would.
|
||||
*
|
||||
* @param taxSplit
|
||||
* the amount per tax component; the component's credit account is where the accounting
|
||||
* entry sends the tax
|
||||
*/
|
||||
public SavingsAccountTransaction deductTds(final LocalDate date, final BigDecimal amount, final Map<TaxComponent, BigDecimal> taxSplit) {
|
||||
final SavingsAccountTransaction tds = SavingsAccountTransaction.withHoldTax(this, office(), date, Money.of(this.currency, amount),
|
||||
taxSplit);
|
||||
addTransaction(tds);
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
recalculateDailyBalances(Money.zero(this.currency), DateUtils.isAfter(date, businessDate) ? date : businessDate, false, false);
|
||||
this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions);
|
||||
return tds;
|
||||
}
|
||||
|
||||
protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) {
|
||||
SavingsAccountTransaction postingTransation = null;
|
||||
List<SavingsAccountTransaction> trans = getTransactions();
|
||||
|
||||
Reference in New Issue
Block a user