Merge branch 'dev-latest' into feature/bugfix
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
2026-09-25 11:24:24 +05:30
60 changed files with 8355 additions and 2768 deletions
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
@@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
return accountMappingDetails;
}
/** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */
@Override
public Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId,
PortfolioProductType.CURRENT.getValue());
if (AccountingValidations.isCashBasedAccounting(accountingType)) {
return setCashSavingsProductToGLAccountMaps(mappings);
}
if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) {
return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings);
}
return null;
}
@Override
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
@@ -24,7 +24,10 @@ public enum PortfolioProductType {
SAVING(2, "productType.saving"), //
CLIENT(5, "productType.client"), //
PROVISIONING(3, "productType.provisioning"), //
SHARES(4, "productType.shares"); //
SHARES(4, "productType.shares"), //
// Current products map their GLs under their own type: sharing SAVING with savings products let a savings
// product and a current product with the same id read each other's GL rows.
CURRENT(6, "productType.current"); //
private final Integer value;
private final String code;
@@ -286,6 +286,15 @@ public class SavingsApiConstants {
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
public static final String validForPeriodParamName = "validForPeriod";
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
// Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an
// amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either.
public static final String odMinLimitParamName = "odMinLimit";
public static final String odSecurityTypeParamName = "odSecurityType";
// Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the
// limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess.
public static final String odExcessAllowedParamName = "odExcessAllowed";
public static final String odExcessLimitPercentParamName = "odExcessLimitPercent";
public static final String odPenalRateParamName = "odPenalRate";
// Status of Account fields
public static final String accountStatusParamName = "accountStatus";
@@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private String avgBalanceCalcPeriod;
@Getter @Setter private String validForPeriod;
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
@Getter @Setter private BigDecimal odMinLimit;
@Getter @Setter private String odSecurityType;
@Getter @Setter private Boolean odExcessAllowed;
@Getter @Setter private BigDecimal odExcessLimitPercent;
@Getter @Setter private BigDecimal odPenalRate;
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
@Getter @Setter private Map<String, Object> accountFacilityConfig;
@@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable {
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
target.setValidForPeriod(source.getValidForPeriod());
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
target.setOdMinLimit(source.getOdMinLimit());
target.setOdSecurityType(source.getOdSecurityType());
target.setOdExcessAllowed(source.getOdExcessAllowed());
target.setOdExcessLimitPercent(source.getOdExcessLimitPercent());
target.setOdPenalRate(source.getOdPenalRate());
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
@@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom {
@Column(name = "status_enum", nullable = false)
private Integer statusEnum = 100;
// SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit
// SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit,
// 600=BlockDebit
@Column(name = "sub_status_enum", nullable = false)
private Integer subStatusEnum = 0;
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "office_id", nullable = false)
private Office office;
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat
@Column(name = "transaction_type_enum", nullable = false)
private Integer transactionTypeEnum;
@@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
return txn;
}
// transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings
public static CurrentAccountTransaction escheat(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate) {
CurrentAccountTransaction txn = new CurrentAccountTransaction();
txn.currentAccount = account;
txn.office = office;
txn.transactionTypeEnum = 19;
txn.amount = amount;
txn.transactionDate = transactionDate;
txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability";
txn.submittedOnDate = transactionDate;
return txn;
}
// transactionTypeEnum = 7 (Reversal / Undo)
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
@@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService {
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
/**
* Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the
* nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done.
*/
boolean escheat(Long accountId);
}
@@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
@@ -136,7 +142,17 @@ public class CurrentProductDataValidator {
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
SavingsApiConstants.glAccountIdParamName));
odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName,
odPenalRateParamName, SavingsApiConstants.glAccountIdParamName));
/** How long an OD sanction on this scheme runs before it has to be renewed. */
private static final List<String> OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y");
/** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */
private static final List<String> OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH");
/** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */
private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50");
/** Excess drawing is a small tolerance over the limit, never a second limit. */
private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25");
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
@@ -260,10 +276,10 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true);
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, null);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -358,7 +374,7 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false);
}
if (null != isDormancyActive && isDormancyActive) {
@@ -380,7 +396,7 @@ public class CurrentProductDataValidator {
}
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, product);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -398,23 +414,103 @@ public class CurrentProductDataValidator {
}
}
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
/**
* The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is
* allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate,
* a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly,
* as RBI requires for advances, or at most quarterly.
*
* On update, product is the stored product and fills in any value the request leaves out, so a partial edit is
* checked against the scheme it will produce; on create it is null.
*/
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element,
final CurrentProduct product) {
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
}
if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) {
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount();
final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))
: product != null && product.isAllowOverdraft();
final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName,
product == null ? null : product.getOverdraftLimit());
final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit());
final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName,
product == null ? null : product.getNominalAnnualInterestRateOverdraft());
final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName,
product == null ? null : product.getMinOverdraftForInterestCalculation());
if (!allowOverdraft) {
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull()
.zeroOrPositiveAmount();
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
return;
}
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) {
final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element);
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount();
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount();
if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) {
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit)
.failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit.");
}
if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) {
final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod());
baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank()
.isOneOfTheseStringValues(OD_VALIDITY_PERIODS);
final String securityType = stringOrStored(element, odSecurityTypeParamName,
product == null ? null : product.getOdSecurityType());
baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank()
.isOneOfTheseStringValues(OD_SECURITY_TYPES);
// Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the
// excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate
// charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance.
final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element))
: product != null && product.isOdExcessAllowed();
if (excessAllowed) {
final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName,
product == null ? null : product.getOdExcessLimitPercent());
baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount()
.notGreaterThanMax(MAX_EXCESS_PERCENT);
final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName,
product == null ? null : product.getOdPenalRate());
baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
}
final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element)
: product == null ? null : product.getInterestPostingPeriodType();
baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull()
.isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(),
SavingsPostingInterestPeriodType.QUATERLY.getValue());
final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element)
: product == null ? null : product.getInterestCalculationDaysInYearType();
baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull()
.isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(),
SavingsInterestCalculationDaysInYearType.DAYS_365.getValue());
}
private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element)
: stored;
}
private String stringOrStored(final JsonElement element, final String paramName, final String stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element)
: stored;
}
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
@@ -430,7 +526,7 @@ public class CurrentProductDataValidator {
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
}
}
@@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import com.google.gson.JsonArray;
import jakarta.persistence.CascadeType;
@@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private boolean odAccountClosedZeroBalanceInDayClosing = false;
// The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest.
@Column(name = "od_min_limit", scale = 6, precision = 19)
private BigDecimal odMinLimit;
// CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...).
@Column(name = "od_security_type", length = 20)
private String odSecurityType;
// Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess
// as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone.
@Column(name = "od_excess_allowed")
private boolean odExcessAllowed = false;
@Column(name = "od_excess_limit_percent", scale = 6, precision = 19)
private BigDecimal odExcessLimitPercent;
@Column(name = "od_penal_rate", scale = 6, precision = 19)
private BigDecimal odPenalRate;
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
private CurrentProductFacilityConfig facilityConfig;
@@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
this.odAccountClosedZeroBalanceInDayClosing = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName);
actualChanges.put(odMinLimitParamName, newValue);
this.odMinLimit = newValue;
}
if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) {
final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName);
actualChanges.put(odSecurityTypeParamName, newValue);
this.odSecurityType = newValue;
}
if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
actualChanges.put(odExcessAllowedParamName, newValue);
this.odExcessAllowed = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName);
actualChanges.put(odExcessLimitPercentParamName, newValue);
this.odExcessLimitPercent = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName);
actualChanges.put(odPenalRateParamName, newValue);
this.odPenalRate = newValue;
}
if (!this.odExcessAllowed) {
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
// Clear OD-specific fields when overdraft is disabled
if (!this.allowOverdraft) {
this.avgBalanceCalcPeriod = null;
this.validForPeriod = null;
this.odAccountClosedZeroBalanceInDayClosing = false;
this.odMinLimit = null;
this.odSecurityType = null;
this.odExcessAllowed = false;
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
return actualChanges;
@@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
public BigDecimal getOdMinLimit() { return odMinLimit; }
public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; }
public String getOdSecurityType() { return odSecurityType; }
public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; }
public boolean isOdExcessAllowed() { return odExcessAllowed; }
public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; }
public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; }
public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; }
public BigDecimal getOdPenalRate() { return odPenalRate; }
public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; }
}
@@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
@@ -314,6 +319,23 @@ public class CurrentProductAssembler {
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
}
if (command.parameterExists(odMinLimitParamName)) {
product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName));
}
if (command.parameterExists(odSecurityTypeParamName)) {
product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName));
}
final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName)
&& command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
product.setOdExcessAllowed(excessAllowed);
if (excessAllowed) {
if (command.parameterExists(odExcessLimitPercentParamName)) {
product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName));
}
if (command.parameterExists(odPenalRateParamName)) {
product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName));
}
}
}
return product;
@@ -0,0 +1,115 @@
/**
* 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.currentproduct.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}.
*
* <p>
* They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product
* and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the
* account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type,
* the two can never meet.
*
* <p>
* A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a
* partial edit changes only what it names. Accounting rule NONE clears every mapping.
*/
@Component
@RequiredArgsConstructor
public class CurrentProductGlMappingWriter {
/** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */
private static final Map<String, Integer> PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>();
static {
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18);
}
private static final int CURRENT = PortfolioProductType.CURRENT.getValue();
private final JdbcTemplate jdbcTemplate;
/**
* Applies the request's mappings to the product and returns what changed, keyed by parameter name.
*
* @param accountingRule
* the product's accounting rule after this request (1 = NONE)
*/
public Map<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
final Map<String, Object> changes = new LinkedHashMap<>();
if (accountingRule == null || accountingRule == 1) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
return changes;
}
final JsonElement parsed = command.parsedJson();
final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
for (final Map.Entry<String, Integer> entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) {
final String param = entry.getKey();
if (!json.has(param)) {
continue;
}
final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong();
final Long existing = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL",
Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null);
if (java.util.Objects.equals(existing, glAccountId)) {
continue;
}
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue());
if (glAccountId != null) {
jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)"
+ " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue());
}
changes.put(param, glAccountId);
}
return changes;
}
/** Removes every mapping of a deleted product. */
public void deleteAll(final Long productId) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
}
}
@@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, ");
sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, ");
sqlBuilder.append("cp.od_penal_rate as odPenalRate, ");
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
@@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
productData.setValidForPeriod(rs.getString("validForPeriod"));
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
productData.setOdMinLimit(rs.getBigDecimal("odMinLimit"));
productData.setOdSecurityType(rs.getString("odSecurityType"));
productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed"));
productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent"));
productData.setOdPenalRate(rs.getBigDecimal("odPenalRate"));
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return productData;
@@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
private final CurrentProductGlMappingWriter glMappingWriter;
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
@@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
}
this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command,
DepositAccountType.SAVINGS_DEPOSIT);
// Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter.
this.glMappingWriter.write(product.getId(), command, product.getAccountingType());
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
}
// accounting related changes
final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName);
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
DepositAccountType.SAVINGS_DEPOSIT);
final Map<String, Object> accountingMappingChanges = this.glMappingWriter.write(product.getId(), command,
product.getAccountingType());
changes.putAll(accountingMappingChanges);
if (!changes.isEmpty()) {
@@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
this.currentProductRepository.delete(product);
this.glMappingWriter.deleteAll(productId);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
@@ -78,11 +79,11 @@ public class CurrentProductConfiguration {
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) {
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter);
}
}
@@ -105,7 +105,18 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ")
// entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and
// current-account entries -- current products deliberately share SAVING's GL type
// space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two
// apart on its own, and m_savings_account/m_current_account each have their own id
// sequence: an entity_id can be a real row in both, making a plain
// COALESCE(ca.account_no, sa.account_no) pick the current account's number even for
// an entry that is genuinely a savings one. savings_transaction_id is the one
// unambiguous signal available here -- only a real savings transaction sets it -- so
// it decides first; only when it's absent (a current-account entry, or a savings
// entry with no linked transaction row) does the current-account guess apply.
.append(" CASE ")
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" ELSE NULL ")
.append(" END as savingsAccountNo ");
@@ -0,0 +1,152 @@
/**
* 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.account.service;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
/**
* A book debit: money leaves a savings or current account by book entry, not as cash.
*
* <p>
* Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...).
* Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry --
* cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no
* report shows.
*
* <p>
* It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance
* (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's
* transaction, so a later failure rolls the debit back with everything else.
*/
@Service
@RequiredArgsConstructor
public class AccountBookDebitService {
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
/** Debits a savings account; returns the m_savings_account_transaction id. */
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId) {
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo());
ps.setBigDecimal(7, balance.subtract(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** Debits a current account; returns the m_current_account_transaction id. */
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId, final String note) {
final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount);
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note);
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction "
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, "
+ "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
+ "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setString(5, refNo());
ps.setDate(6, sqlDate);
ps.setBigDecimal(7, balance.subtract(amount));
ps.setString(8, remarks);
ps.setLong(9, userId);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.",
accountId);
}
final int status = ((Number) rows.get(0).get("status_enum")).intValue();
if (status != 300) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active",
kind + " account " + accountId + " is not active.", accountId);
}
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("account_balance_derived");
final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("on_hold_funds_derived");
final BigDecimal available = balance.subtract(onHold);
if (available.compareTo(amount) < 0) {
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
"Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available
+ ", Requested: " + amount,
accountId);
}
return balance;
}
private static String refNo() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 20);
}
}
@@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
@@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final AccountingProcessorHelper accountingProcessorHelper;
private final KycRestrictionGuard kycRestrictionGuard;
@Transactional
@Override
@@ -472,7 +474,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
toSavingsAccount.productId(), toSavingsAccount.getId());
toSavingsAccount.productId(), false, toSavingsAccount.getId());
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
@@ -517,7 +519,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromSavingsAccount.productId(), fromSavingsAccount.getId());
fromSavingsAccount.productId(), false, fromSavingsAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
@@ -545,10 +547,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
toCurrentAccount.getProductId(), toCurrentAccount.getId());
toCurrentAccount.getProductId(), true, toCurrentAccount.getId());
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
@@ -681,6 +683,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate) {
// Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is.
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -699,10 +703,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
currentAccountRepository.save(account);
if (account.getProductId() != null && txn.getId() != null) {
final String currentTxnId = "CA" + txn.getId();
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
account.getId(), currentTxnId, txnDate, amount, false);
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
account.getId(), currentTxnId, txnDate, amount);
}
return txn;
}
@@ -712,7 +715,9 @@ 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 Long counterpartEntityId) {
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;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -730,7 +735,8 @@ 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, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -738,7 +744,9 @@ 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 Long counterpartEntityId) {
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;
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.add(amount);
@@ -749,7 +757,8 @@ 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, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -783,7 +792,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
final Long counterpartProductId, final Long counterpartEntityId) {
final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) {
if (account.getProductId() == null || txn.getId() == null) {
return;
}
@@ -794,17 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final String currentTxnId = "CA" + txn.getId();
if (isWithdrawal) {
// DR: current account GL CR: counterpart (savings/current) GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
account.getProductId(), account.getId(),
counterpartProductId, 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.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartEntityId,
account.getProductId(), account.getId(),
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
currentTxnId, txnDate, amount);
}
}
@@ -1,140 +1,141 @@
/**
* 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.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
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.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
/**
* 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.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
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.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
@@ -423,7 +423,13 @@ public class CashierDepositRequestApiResource {
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
// the very same m_savings_account table as a plain savings account, distinguished only by
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
final String depositCategory = resolveDepositAccountCategory(savingsAccountId);
// The account_no is also checked -- m_current_account and m_savings_account each keep
// their own identity sequence, so the same numeric id can legitimately belong to a
// current account AND an unrelated savings account at once. An id match alone once sent
// a real deposit into a stranger's current account instead of the savings account the
// request actually named; account_no is the one value that can't collide between them.
final String requestAccountNo = (String) record.get("account_no");
final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo);
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
@@ -578,7 +584,8 @@ public class CashierDepositRequestApiResource {
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
final String description = (String) record.get("description");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) {
final String requestAccountNo = (String) record.get("account_no");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) {
throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits.");
}
@@ -863,11 +870,30 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/** Returns true if this account ID belongs to m_current_account (not m_savings_account). */
private boolean isCurrentAccountById(final Long accountId) {
/**
* Returns true if this account ID belongs to m_current_account (not m_savings_account).
*
* m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say)
* can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two
* different clients, sharing nothing but a coincidental number. An id-only check here once sent
* a savings deposit into a stranger's current account: id=1 existed in m_current_account, so
* this returned true, even though the request was for m_savings_account id=1.
*
* expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is
* only trusted once the account_no on that current-account row confirms it is truly the same
* account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller
* has no account_no to compare against, e.g. an id already known to be unambiguous).
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
@@ -880,12 +906,15 @@ public class CashierDepositRequestApiResource {
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*
* expectedAccountNo guards the current-account check against an id collision with
* m_savings_account — see {@link #isCurrentAccountById}.
*/
private String resolveDepositAccountCategory(final Long accountId) {
private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId)) {
if (isCurrentAccountById(accountId, expectedAccountNo)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
@@ -340,7 +340,7 @@ public class ChequeBookIssuanceChargeService {
private Long getCurrentControlGlId(final Long currentAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
@@ -50,7 +50,7 @@ public class ClearingGlAccountResolverService {
if ("CURRENT".equalsIgnoreCase(accountType)) {
return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId);
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", accountId);
}
return null;
}
@@ -173,9 +173,16 @@ public class ClerkDisbursementRequestApiResource {
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
// working, since nothing about a restricted customer should prevent them being paid.
// A GL leg carries no savingsAccountId and the guard ignores it.
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
// m_current_account -- checking it as a savings id would test some unrelated savings account.
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
: KycRestrictionGuard.Operation.CREDIT;
if ("CURRENT".equals(entryAccountType)) {
kycRestrictionGuard.checkCurrentAccount(entry.get("savingsAccountId").getAsLong(), operation);
} else {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), operation);
}
}
// A Dr leg against a savings account is a withdrawal, and the clerk who raises it should
// not learn the account can't pay out until a manager tries to approve it and the whole
@@ -522,6 +529,7 @@ public class ClerkDisbursementRequestApiResource {
// collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode).
// disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode).
final List<GlEntry> collectionGlEntries = new ArrayList<>();
final List<GlEntry> collectionCreditGlEntries = new ArrayList<>();
final List<GlEntry> disbursementGlEntries = new ArrayList<>();
// Compensation list: each successfully posted Fineract transaction gets an undo lambda.
@@ -543,8 +551,11 @@ public class ClerkDisbursementRequestApiResource {
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrent = "CURRENT".equals(
entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
// Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can
// exist in both m_current_account and m_savings_account at once, and a bank
// transfer posts and moves money immediately, with no maker-checker step to catch
// a wrong label before it does.
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
@@ -642,8 +653,9 @@ public class ClerkDisbursementRequestApiResource {
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
} else {
// Collection: CR GL is the target for the combined savings debit entry
// Collection: each CR GL takes its own amount in the one combined entry
collectionTargetGlId = glId;
collectionCreditGlEntries.add(new GlEntry(glId, entryAmt, acNo, null));
}
}
txnRefs.add("GL:" + acNo);
@@ -651,7 +663,7 @@ public class ClerkDisbursementRequestApiResource {
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrentAccount = isCurrentAccountById(accountId);
final boolean isCurrentAccount = isCurrentAccountById(accountId, acNo);
String txnId = null;
if ("Cr".equals(dbCr)) {
@@ -688,28 +700,26 @@ public class ClerkDisbursementRequestApiResource {
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
}
} else {
try {
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
} catch (IdempotentCommandProcessSucceedException idempotent) {
txnId = extractResourceIdFromResponse(idempotent.getResponse());
// Collection: a direct book debit, exactly as a bank transfer debits. It used to run a
// cash withdrawal, whose own auto-entry (DR control / CR CashVault -- cash that never
// moved) was then hidden by flagging it reversed, and a current account's auto-entry was
// not even hidden, crediting CashVault instead of the target GL. Now nothing is posted
// here; the one combined entry below is the only GL posting.
final Long ctrlGl = isCurrentAccount ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
if (ctrlGl == null) {
throw new RuntimeException("No control GL mapped for account " + acNo
+ ". Map it on the account's product Accounting tab.");
}
if (txnId != null) {
registerUndo(undoOps, accountId, txnId, isCurrentAccount);
txnRefs.add(txnId);
if (!isCurrentAccount) {
final String fullTxnId = "S" + txnId;
withdrawalSavingsTxnRefs.add(txnId);
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
// Savings withdrawal auto-creates: DR SavingsControl / CR CashVault.
// Collect the SavingsControl GL (DR side, type_enum=2) so we can
// post a direct DR SavingsControl / CR targetGL combined entry.
final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2);
if (savingsCtrlGl != null) {
collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId));
}
}
final long debitTxnId = isCurrentAccount
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(debitTxnId));
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
if (isCurrentAccount) {
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
} else {
undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, debitTxnId));
}
}
}
@@ -732,39 +742,22 @@ public class ClerkDisbursementRequestApiResource {
}
// ── Collection GL entry ───────────────────────────────────────────────
// Goal: DR SavingsControl(per account) / CR targetGL — one combined entry.
// Fineract withdrawals auto-create: DR SavingsControl / CR CashVault.
// We mark those auto-entries reversed (hidden from GL reports) and post
// the direct combined entry. Fallback: CashVault bridge if GL lookup fails.
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
if (!collectionGlEntries.isEmpty()
&& collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) {
markTxnGlEntriesReversed(collectionGlEntries);
final List<GlEntry> snapC = new ArrayList<>(collectionGlEntries);
undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC));
final String collGlTxnId = postCombinedJournalEntry(
collectionGlEntries,
List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)),
officeId, currencyCode, txnDateStr,
buildGlEntryComment("Collection", collectionGlEntries, description));
// Link each DR entry to its savings account so ACCOUNT NO shows in the GL view.
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
approvedGlTxnId = collGlTxnId;
} else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) {
approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description);
} else if (!withdrawalSavingsTxnRefs.isEmpty()) {
// No bridging entry was needed: the withdrawal's own auto-posted credit
// leg already lands on the target GL directly (the savings product's own
// payment-type mapping points there). That withdrawal's own transaction is
// therefore the real posting -- there is no second one to remember instead.
approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0);
}
// One combined entry and nothing else: DR each account's own product control GL / CR each
// target GL. The accounts were debited directly above, so there is no auto-posted entry to
// hide and no CashVault leg to bridge.
if (!collectionGlEntries.isEmpty()) {
if (collectionCreditGlEntries.isEmpty()) {
throw new RuntimeException("Collection has no credit GL to receive " + withdrawalSavingsTotal + ".");
}
final String collGlTxnId = postCombinedJournalEntry(
collectionGlEntries, collectionCreditGlEntries,
officeId, currencyCode, txnDateStr,
buildGlEntryComment("Collection", collectionGlEntries, description));
// Link each DR entry to its savings/current account so ACCOUNT NO shows in the GL view.
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
approvedGlTxnId = collGlTxnId;
}
} // end else (disbursement/collection mode)
@@ -969,8 +962,11 @@ public class ClerkDisbursementRequestApiResource {
final Long accountId = e.get("savingsAccountId").getAsLong();
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
final boolean isCurrent = "CURRENT".equals(acTyp);
// Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can
// exist in both m_current_account and m_savings_account at once, and a bank transfer
// posts and moves money immediately, with no maker-checker step to catch a wrong
// label before it does.
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
@@ -996,8 +992,8 @@ public class ClerkDisbursementRequestApiResource {
final Long accountId = e.get("savingsAccountId").getAsLong();
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
final boolean isCurrent = "CURRENT".equals(acTyp);
// Not trusted from entry.accountType alone -- see isCurrentAccountById.
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
@@ -1371,10 +1367,25 @@ public class ClerkDisbursementRequestApiResource {
commandsSourceWritePlatformService.logCommandSource(cmd);
}
private boolean isCurrentAccountById(final Long accountId) {
/**
* m_current_account and m_savings_account each keep their own identity sequence, so the same
* numeric id can be a real row in both at once -- two unrelated accounts, possibly two different
* clients, sharing nothing but a coincidental number. An id-only check here has already sent a
* real deposit into a stranger's current account instead of the savings account the entry
* actually named. expectedAccountNo -- the entry's own accountNo -- closes that gap: an id match
* is only trusted once the account_no on that current-account row confirms it. Pass null to skip
* the check where the caller has no account_no to compare against.
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank() || "?".equals(expectedAccountNo)) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
@@ -1590,35 +1601,6 @@ public class ClerkDisbursementRequestApiResource {
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
}
/**
* Marks all Fineract auto-created journal entries for these transactions as reversed=true.
* Reversed entries are excluded from GL reports — only our direct combined entry is visible.
* Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails.
*/
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
// Guarded by reversed = false: if transaction_id text is ever reused, this can
// only flip rows that are currently live, never re-touch an already-reversed
// unrelated transaction's rows.
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
e.txnId);
}
}
}
/** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */
private void unmarkTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true",
e.txnId);
}
}
}
/**
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
@@ -1735,7 +1717,7 @@ public class ClerkDisbursementRequestApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1",
currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
@@ -470,7 +470,7 @@ public class ClerkWithdrawalRequestApiResource {
String txnId = null;
if (savingsAccountId != null) {
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description);
@@ -872,10 +872,25 @@ public class ClerkWithdrawalRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private boolean isCurrentAccountById(final Long accountId) {
/**
* m_current_account and m_savings_account each keep their own identity sequence, so the same
* numeric id can be a real row in both at once -- two unrelated accounts, possibly two different
* clients, sharing nothing but a coincidental number. An id-only check here can send a real
* withdrawal against a stranger's current account instead of the savings account the request
* actually named. expectedAccountNo -- the request's own account_no -- closes that gap: an id
* match is only trusted once the account_no on that current-account row confirms it. Pass null
* to skip the check where the caller has no account_no to compare against.
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
@@ -108,8 +108,20 @@ public class KycRestrictionGuard {
checkKycExpiry(savingsAccountId, operation);
}
/**
* The same two restrictions for a current account, read off the current account and its own current product
* (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's.
*/
public void checkCurrentAccount(final Long currentAccountId, final Operation operation) {
checkCurrentAccountDormancy(currentAccountId, operation);
enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation);
}
private void checkKycExpiry(final Long savingsAccountId, final Operation operation) {
final Long daysOverdue = daysOverdue(savingsAccountId);
enforceKycStage(daysOverdue(savingsAccountId), operation);
}
private void enforceKycStage(final Long daysOverdue, final Operation operation) {
if (daysOverdue == null) {
return;
}
@@ -158,6 +170,28 @@ public class KycRestrictionGuard {
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0
""", savingsAccountId);
refuseIfDormant(rows, operation, "savings");
}
private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) {
if (currentAccountId == null || operation == Operation.CREDIT) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT cp.days_to_dormancy,
(SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE')
- GREATEST(ca.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = ca.client_id AND k.verified_on IS NOT NULL),
ca.activatedon_date)) AS days_idle
FROM m_current_account ca
JOIN m_current_product cp ON cp.id = ca.product_id
WHERE ca.id = ? AND cp.is_dormancy_tracking_active = true AND cp.days_to_dormancy > 0
""", currentAccountId);
refuseIfDormant(rows, operation, "current");
}
private void refuseIfDormant(final List<Map<String, Object>> rows, final Operation operation, final String accountKind) {
if (rows.isEmpty()) {
return;
}
@@ -168,11 +202,30 @@ public class KycRestrictionGuard {
}
final long years = threshold.longValue() / 365;
final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days";
throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(),
throw new PlatformDataIntegrityException("error.msg." + accountKind + ".account.dormant." + operation.name().toLowerCase(),
operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for "
+ period + ". It must be reactivated after the customer's identity is re-verified (re-KYC).");
}
private Long currentAccountDaysOverdue(final Long currentAccountId) {
if (currentAccountId == null) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue
FROM m_current_account ca
JOIN m_client_kyc k ON k.client_id = ca.client_id
WHERE ca.id = ? AND k.expiry_date IS NOT NULL
ORDER BY k.expiry_date DESC
LIMIT 1
""", currentAccountId);
if (rows.isEmpty()) {
return null;
}
final Number daysOverdue = (Number) rows.get(0).get("days_overdue");
return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue();
}
/**
* Days past the customer's re-KYC due date, or null where there is nothing overdue to measure.
*
@@ -0,0 +1,850 @@
/**
* 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.ossreturn.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Live computation of the RBI off-site surveillance returns OSS 1 (RAL) and OSS 2 (RoE).
*
* <h3>What these returns are</h3>
*
* OSS 1 is the bank's balance sheet restated on RBI's own line structure; OSS 2 is its profit and
* loss account on the same footing. Both are quarterly, both are denominated in thousands of
* rupees, and both are filed through RBI's CIMS portal.
*
* <h3>Where every figure comes from</h3>
*
* Nothing here is a constant. Each return line names a role; the GL accounts pinned to that role
* decide the figure, and the ledger decides its value:
*
* <ul>
* <li><b>Balance-sheet roles</b> (OSS 1) take the closing balance as at the quarter end - every
* journal entry on or before that date, on the account's own natural side.</li>
* <li><b>Earnings roles</b> (OSS 2) take the movement over the quarter, because an income or
* expense line reports what was earned or spent in the period, not a balance carried forward.</li>
* <li><b>Counts</b> (depositors, borrowers, members) come from the portfolio tables, since a
* headcount has no ledger balance to read.</li>
* </ul>
*
* The bank's own identity - name, RBI bank code, regional office, signatories - is read from
* {@code m_institution_profile}, the single place the institution's particulars are maintained.
* Nothing about the bank is written into this file.
*
* <h3>Blank is not zero</h3>
*
* A role with no accounts pinned to it reports {@code null} and says so in
* {@code unmetRequirements}. It never reports 0.00. To RBI a nil figure is an assertion that the
* bank holds nothing under that head; an unmapped role is an assertion about nothing at all, and
* the two must not be filed as though they were the same statement.
*/
@Path("/v1/ossreturn")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class OssReturnApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN";
/** Returns are denominated in thousands of rupees; the ledger is in rupees. */
private static final BigDecimal RETURN_SCALE = BigDecimal.valueOf(1000);
/**
* acc_gl_account.classification_enum. Only the debit-natured classifications are named, since
* they are what decides which way round a balance is read; everything else is credit-natured.
*/
private static final int ASSET = 1;
private static final int INCOME = 4;
private static final int EXPENSE = 5;
/** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */
private static final int POSTABLE_ACCOUNT = 1;
/**
* Pseudo-role for the year's own result. Not a configurable role: it is derived from whatever
* income and expense accounts exist, so it cannot be left unmapped and cannot be mapped wrong.
*/
private static final String CURRENT_YEAR_RESULT = "__CURRENT_YEAR_RESULT__";
/** m_savings_account.status_enum for an active account. */
private static final int SAVINGS_ACTIVE = 300;
/** m_loan.loan_status_id for an active loan. */
private static final int LOAN_ACTIVE = 300;
/**
* m_savings_account.deposit_type_enum, per DepositAccountType. RBI's "Term Deposits" head
* covers both fixed and recurring deposits, so the two are counted together there.
*/
private static final int SAVINGS_DEPOSIT = 100;
private static final List<Integer> TERM_DEPOSIT_TYPES = List.of(200, 300);
/** How RBI prints dates on the General Information sheet. */
private static final DateTimeFormatter RBI_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy", Locale.ENGLISH);
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
// ─────────────────────────── OSS 1 — RAL ───────────────────────────
/**
* OSS 1 - Return on Assets and Liabilities, return code R070.
*
* @param quarterEndDateParam
* any date in the quarter being reported; the quarter end is derived from it, so a
* caller never has to know which day a quarter ends on. Defaults to the current
* business date.
*/
@GET
@Path("oss1")
public String oss1(@QueryParam("quarterEndDate") final String quarterEndDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate requested = parseOrBusinessDate(quarterEndDateParam);
final Quarter quarter = Quarter.containing(requested);
final Map<String, Object> response = baseResponse("R070", "Return on Assets and Liabilities (RAL)", quarter,
officeId);
response.put("basis", Map.of("asOnDate", quarter.end.toString(), "note",
"Balance-sheet figures are the closing position as at " + quarter.end
+ "; every journal entry on or before that date is included."));
// Liabilities and assets, in the order the RBI workbook prints them. Each line names the
// role whose mapped accounts produce it; a line with no role of its own is a subtotal and
// is summed from the lines it covers, never mapped separately.
final List<Map<String, Object>> liabilities = new ArrayList<>();
final RoleFigures figures = new RoleFigures(quarter, officeId);
liabilities.add(balanceLine("1. Paid-up Capital", "OSS1_PAID_UP_CAPITAL", figures));
liabilities.add(balanceLine("2. Reserve Fund and other Reserves", "OSS1_RESERVES", figures));
liabilities.add(balanceLine("3. Profit (+) / Loss (-)", "OSS1_PROFIT_LOSS", figures));
// The year's own result, which the ledger has not appropriated to reserves yet. RBI prints
// it as its own head for exactly that reason: between appropriations the balance sheet is
// short by what the bank has earned or lost so far, and without this line the return would
// not balance even though the books do. Derived from the income and expense accounts rather
// than mapped, since it is not a balance any account carries.
final BigDecimal currentYearResult = currentYearResult(quarter, officeId);
liabilities.add(derivedLine(
currentYearResult.signum() >= 0 ? "3 iii. Operating Surplus in Current year"
: "3 iv. Operating Deficit in Current year",
CURRENT_YEAR_RESULT, currentYearResult));
liabilities.add(subtotal("4. Total Capital And Reserves", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES",
"OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT));
liabilities.add(balanceLine("5. Subordinated Debt (Tier II Capital)", "OSS1_SUBORDINATED_DEBT", figures));
liabilities.add(balanceLine("6. Deposits and other Accounts", "OSS1_DEPOSITS", figures));
liabilities.add(countLine("Total No of Depositors", countDepositors(officeId, quarter.end, null)));
liabilities.add(balanceLine("i. Term Deposits (including recurring deposits)", "OSS1_DEPOSITS_TERM", figures));
liabilities.add(
countLine("No. of Term Depositors", countDepositors(officeId, quarter.end, TERM_DEPOSIT_TYPES)));
liabilities.add(balanceLine("ii. Savings Bank Deposits", "OSS1_DEPOSITS_SAVINGS", figures));
liabilities.add(countLine("No of Savings Depositors",
countDepositors(officeId, quarter.end, List.of(SAVINGS_DEPOSIT))));
liabilities.add(balanceLine("iii. Current Deposits", "OSS1_DEPOSITS_CURRENT", figures));
liabilities.add(balanceLine("7. Borrowings", "OSS1_BORROWINGS", figures));
liabilities.add(balanceLine("8. Other Liabilities and Provisions", "OSS1_OTHER_LIABILITIES", figures));
// The deposit sub-heads are a breakdown of OSS1_DEPOSITS, not additions to it, so the total
// names the head and leaves them out rather than counting the same money twice.
liabilities.add(subtotal("15. Total Liabilities", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES",
"OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT, "OSS1_SUBORDINATED_DEBT", "OSS1_DEPOSITS", "OSS1_BORROWINGS",
"OSS1_OTHER_LIABILITIES"));
final List<Map<String, Object>> assets = new ArrayList<>();
assets.add(balanceLine("1. Cash in Hand", "OSS1_CASH_IN_HAND", figures));
assets.add(balanceLine("2. Balances with Banks", "OSS1_BALANCE_WITH_BANKS", figures));
assets.add(balanceLine("3. Investments", "OSS1_INVESTMENTS", figures));
assets.add(balanceLine("4. Loans and Advances", "OSS1_LOANS_ADVANCES", figures));
assets.add(countLine("Total No of Borrowers", borrowerCount(officeId, quarter.end)));
assets.add(balanceLine("5. Fixed Assets", "OSS1_FIXED_ASSETS", figures));
assets.add(balanceLine("6. Other Assets", "OSS1_OTHER_ASSETS", figures));
assets.add(subtotal("12. Total Assets", assets, "OSS1_CASH_IN_HAND", "OSS1_BALANCE_WITH_BANKS",
"OSS1_INVESTMENTS", "OSS1_LOANS_ADVANCES", "OSS1_FIXED_ASSETS", "OSS1_OTHER_ASSETS"));
response.put("liabilities", liabilities);
response.put("assets", assets);
// Liabilities must equal assets. Reported rather than asserted: an imbalance is a real
// condition of the books that the filer has to see, not something to hide behind an error.
final BigDecimal totalLiabilities = amountOf(liabilities, "15. Total Liabilities");
final BigDecimal totalAssets = amountOf(assets, "12. Total Assets");
response.put("totalLiabilities", totalLiabilities);
response.put("totalAssets", totalAssets);
response.put("balanced", totalLiabilities.compareTo(totalAssets) == 0);
response.put("imbalance", totalAssets.subtract(totalLiabilities));
response.put("unmetRequirements", figures.unmet());
response.put("computable", figures.anyConfigured());
return gson.toJson(response);
}
// ─────────────────────────── OSS 2 — RoE ───────────────────────────
/**
* OSS 2 - Return on Earnings, return code R071.
*
* Earnings lines report the movement over the quarter rather than a closing balance, so the
* period matters here in a way it does not on OSS 1: the same account read as a balance would
* carry forward every earlier quarter's income as well.
*/
@GET
@Path("oss2")
public String oss2(@QueryParam("quarterEndDate") final String quarterEndDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate requested = parseOrBusinessDate(quarterEndDateParam);
final Quarter quarter = Quarter.containing(requested);
final Map<String, Object> response = baseResponse("R071", "Return on Earnings (RoE)", quarter, officeId);
response.put("basis", Map.of("fromDate", quarter.start.toString(), "toDate", quarter.end.toString(), "note",
"Earnings are the movement over " + quarter.start + " to " + quarter.end
+ ", not a closing balance: an income or expense line reports what was earned or spent "
+ "in the quarter."));
final RoleFigures figures = new RoleFigures(quarter, officeId);
final List<Map<String, Object>> earnings = new ArrayList<>();
earnings.add(movementLine("2. Interest/discount received on loans and advances", "OSS2_INTEREST_ON_ADVANCES",
figures));
earnings.add(movementLine("3. Interest on market lending (to banks)", "OSS2_INTEREST_ON_MARKET_LENDING",
figures));
earnings.add(movementLine("4. Interest on investments", "OSS2_INTEREST_ON_INVESTMENTS", figures));
earnings.add(movementLine("5. Interest on balances with RBI and inter-bank deposits",
"OSS2_INTEREST_ON_BANK_BALANCES", figures));
earnings.add(subtotal("1. Interest/discount received (2+3+4+5)", earnings, "OSS2_INTEREST_ON_ADVANCES",
"OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES"));
earnings.add(movementLine("6. Commission, Exchange and Brokerage", "OSS2_COMMISSION_EXCHANGE_BROKERAGE",
figures));
earnings.add(movementLine("7. Profit on sale of investments", "OSS2_PROFIT_ON_INVESTMENTS", figures));
earnings.add(movementLine("8. Other income", "OSS2_OTHER_INCOME", figures));
final List<Map<String, Object>> expenses = new ArrayList<>();
expenses.add(movementLine("10. Interest paid on deposits", "OSS2_INTEREST_PAID_ON_DEPOSITS", figures));
expenses.add(movementLine("11. Interest paid on borrowings", "OSS2_INTEREST_PAID_ON_BORROWINGS", figures));
expenses.add(movementLine("12. Salaries, allowances and provident fund", "OSS2_STAFF_COST", figures));
expenses.add(movementLine("13. Rent, taxes, insurance and lighting", "OSS2_RENT_TAXES_INSURANCE", figures));
expenses.add(movementLine("14. Depreciation on bank's property", "OSS2_DEPRECIATION", figures));
expenses.add(movementLine("15. Provisions and contingencies", "OSS2_PROVISIONS", figures));
expenses.add(movementLine("16. Other operating expenses", "OSS2_OTHER_OPERATING_EXPENSES", figures));
response.put("earnings", earnings);
response.put("expenses", expenses);
// Item 1 is the sum of items 2 to 5, so total income counts the four components and the
// standalone heads - never item 1 as well, which would count that interest twice.
final BigDecimal totalIncome = sumOfRoles(earnings, "OSS2_INTEREST_ON_ADVANCES",
"OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES",
"OSS2_COMMISSION_EXCHANGE_BROKERAGE", "OSS2_PROFIT_ON_INVESTMENTS", "OSS2_OTHER_INCOME");
final BigDecimal totalExpenditure = sumOfRoles(expenses, "OSS2_INTEREST_PAID_ON_DEPOSITS",
"OSS2_INTEREST_PAID_ON_BORROWINGS", "OSS2_STAFF_COST", "OSS2_RENT_TAXES_INSURANCE",
"OSS2_DEPRECIATION", "OSS2_PROVISIONS", "OSS2_OTHER_OPERATING_EXPENSES");
response.put("totalIncome", totalIncome);
response.put("totalExpenditure", totalExpenditure);
response.put("operatingProfit", totalIncome.subtract(totalExpenditure));
response.put("unmetRequirements", figures.unmet());
response.put("computable", figures.anyConfigured());
return gson.toJson(response);
}
// ─────────────────────────── shared response scaffolding ───────────────────────────
/**
* The General Information block every OSS return carries, built from the institution's own
* particulars so OSS 1 and OSS 2 can never state a different bank from one another.
*/
private Map<String, Object> baseResponse(final String returnCode, final String returnName, final Quarter quarter,
final Long officeId) {
final Map<String, Object> response = new LinkedHashMap<>();
response.put("returnCode", returnCode);
response.put("returnName", returnName);
response.put("reportingFrequency", "Quarterly");
final Map<String, Object> profile = institutionProfile();
final List<Map<String, Object>> generalInfo = new ArrayList<>();
generalInfo.add(info("Return Name", returnName));
generalInfo.add(info("Return Code", returnCode));
generalInfo.add(info("Bank Name", text(profile.get("bank_name"))));
generalInfo.add(info("Bank Code", text(profile.get("bank_code"))));
generalInfo.add(info("RO Name", text(profile.get("city"))));
generalInfo.add(info("Institution Type", text(profile.get("bank_type"))));
generalInfo.add(info("Reporting Frequency", "Quarterly"));
generalInfo.add(info("Start Date", quarter.start.format(RBI_DATE)));
generalInfo.add(info("Quarter End Date", quarter.end.format(RBI_DATE)));
response.put("generalInfo", generalInfo);
response.put("quarter", Map.of("label", quarter.label, "startDate", quarter.start.toString(), "endDate",
quarter.end.toString()));
response.put("officeId", officeId);
response.put("officeName", officeName(officeId));
response.put("signatories", signatories(profile, quarter));
return response;
}
/**
* The institution's particulars. Absent columns are tolerated rather than fatal: the return
* names what is missing on the General Information sheet, which is more useful than refusing to
* compute figures that are themselves correct.
*/
private Map<String, Object> institutionProfile() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT bank_name, bank_code, bank_type, city, state,
authorized_person_name, authorized_person_designation, authorized_person_email,
contact_phone
FROM m_institution_profile WHERE id = 1
""");
return rows.isEmpty() ? Map.of() : rows.get(0);
}
/**
* The signatory block, taken from the institution's authorised person. Dated to the quarter
* end rather than to today, so a return reprinted later still reads as filed for its own
* period.
*/
private List<Map<String, Object>> signatories(final Map<String, Object> profile, final Quarter quarter) {
final Map<String, Object> signatory = new LinkedHashMap<>();
signatory.put("heading", "Authorised Reporting Official");
signatory.put("name", text(profile.get("authorized_person_name")));
signatory.put("designation", text(profile.get("authorized_person_designation")));
signatory.put("email", text(profile.get("authorized_person_email")));
signatory.put("telephoneOffice", text(profile.get("contact_phone")));
signatory.put("place", text(profile.get("city")));
signatory.put("date", quarter.end.format(RBI_DATE));
return List.of(signatory);
}
private String officeName(final Long officeId) {
if (officeId == null) {
return "All offices (consolidated)";
}
final List<String> names = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class,
officeId);
return names.isEmpty() ? "Office " + officeId : names.get(0);
}
private static Map<String, Object> info(final String label, final String value) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
// An unset particular is reported as such rather than as an empty cell, so a reviewer can
// see that it needs maintaining in the institution profile.
out.put("value", value.isBlank() ? "Not set in Institution Profile" : value);
out.put("configured", !value.isBlank());
return out;
}
private static String text(final Object value) {
return value == null ? "" : value.toString().trim();
}
// ─────────────────────────── the quarter ───────────────────────────
/** The reporting quarter a date falls in. RBI quarters run Apr-Jun, Jul-Sep, Oct-Dec, Jan-Mar. */
private static final class Quarter {
final LocalDate start;
final LocalDate end;
final String label;
private Quarter(final LocalDate start, final LocalDate end, final String label) {
this.start = start;
this.end = end;
this.label = label;
}
static Quarter containing(final LocalDate date) {
final int month = date.getMonthValue();
final int startMonth = ((month - 1) / 3) * 3 + 1;
final LocalDate start = date.withMonth(startMonth).withDayOfMonth(1);
final LocalDate end = start.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth());
return new Quarter(start, end, "Q" + ((startMonth - 1) / 3 + 1) + " " + start.getYear());
}
}
// ─────────────────────────── figures from the ledger ───────────────────────────
/**
* Resolves return lines to ledger figures, remembering which roles were unmapped or partly
* unusable so the return can report them once at the end.
*/
private final class RoleFigures {
private final Quarter quarter;
private final Long officeId;
private final List<String> reasons = new ArrayList<>();
private boolean anyConfigured;
RoleFigures(final Quarter quarter, final Long officeId) {
this.quarter = quarter;
this.officeId = officeId;
}
/** Closing balance as at the quarter end - for the balance-sheet lines of OSS 1. */
RoleFigure closing(final String roleKey) {
return figureFor(roleKey, null, quarter.end, officeId, this);
}
/** Movement over the quarter - for the earnings lines of OSS 2. */
RoleFigure movement(final String roleKey) {
return figureFor(roleKey, quarter.start, quarter.end, officeId, this);
}
/**
* Named by the line as the return prints it rather than by the role key: the reader is
* looking at a return, and "Investments" is what they will look for on the configuration
* screen, not OSS1_INVESTMENTS.
*/
void noteUnmapped(final String roleKey) {
reasons.add(lineName(roleKey) + " — no GL accounts mapped, so this line reports nothing.");
}
void noteExcluded(final String roleKey, final List<String> excluded) {
reasons.add(lineName(roleKey) + " — leaves out " + String.join(", ", excluded)
+ ", mapped but no longer postable. The figure shown is short by them.");
}
void noteConfigured() {
anyConfigured = true;
}
boolean anyConfigured() {
return anyConfigured;
}
List<String> unmet() {
return reasons;
}
}
/** A role's figure, plus whether it was configured at all - zero and unset are different. */
private static final class RoleFigure {
final String roleKey;
boolean configured;
BigDecimal amount = BigDecimal.ZERO;
final List<Map<String, Object>> accounts = new ArrayList<>();
final List<String> excluded = new ArrayList<>();
RoleFigure(final String roleKey) {
this.roleKey = roleKey;
}
}
/**
* Sums the GL accounts pinned to a role.
*
* With {@code fromDate} null the figure is the closing balance as at {@code toDate}; with a
* from-date it is the movement between the two, which is what an earnings line reports.
*
* Every pinned account is read back, usable or not. Filtering the unusable ones out in SQL
* would shrink the figure with nothing on the return to say an account had dropped out, so
* they are carried through and named instead.
*/
private RoleFigure figureFor(final String roleKey, final LocalDate fromDate, final LocalDate toDate,
final Long officeId, final RoleFigures owner) {
final RoleFigure figure = new RoleFigure(roleKey);
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage
FROM m_rbi_reserve_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
WHERE c.config_key = ?
ORDER BY a.gl_code
""", roleKey);
if (accounts.isEmpty()) {
owner.noteUnmapped(roleKey);
return figure;
}
figure.configured = true;
owner.noteConfigured();
for (final Map<String, Object> account : accounts) {
final Long accountId = ((Number) account.get("id")).longValue();
final int classification = ((Number) account.get("classification_enum")).intValue();
final String glCode = text(account.get("gl_code"));
// A pinned account can stop being usable after it was mapped: disabled, or turned into
// a header whose balance is the sum of its children and would double-count them.
final boolean disabled = Boolean.TRUE.equals(account.get("disabled"));
final boolean header = account.get("account_usage") != null
&& ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT;
if (disabled || header) {
figure.excluded
.add(glCode + " — " + text(account.get("name")) + (disabled ? " (disabled)" : " (header)"));
final Map<String, Object> skipped = new LinkedHashMap<>();
skipped.put("glAccountId", accountId);
skipped.put("glCode", glCode);
skipped.put("name", text(account.get("name")));
skipped.put("balance", null);
skipped.put("excluded", disabled ? "disabled" : "header");
figure.accounts.add(skipped);
continue;
}
final BigDecimal balance = balanceOf(accountId, classification, fromDate, toDate, officeId);
figure.amount = figure.amount.add(balance);
final Map<String, Object> detail = new LinkedHashMap<>();
detail.put("glAccountId", accountId);
detail.put("glCode", glCode);
detail.put("name", text(account.get("name")));
detail.put("balance", scaled(balance));
figure.accounts.add(detail);
}
if (!figure.excluded.isEmpty()) {
owner.noteExcluded(roleKey, figure.excluded);
}
return figure;
}
/**
* One account's ledger figure, on its own natural side so a liability reports positive rather
* than as a negative asset.
*
* Reversed entries and the reversal entries that cancel them are both excluded, so a reversal
* nets to nothing rather than to twice the original.
*/
private BigDecimal balanceOf(final Long accountId, final int classification, final LocalDate fromDate,
final LocalDate toDate, final Long officeId) {
final StringBuilder sql = new StringBuilder("""
SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit,
COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit
FROM acc_gl_journal_entry
WHERE account_id = ? AND entry_date <= ?
AND (reversed IS NULL OR reversed = false)
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""");
final List<Object> args = new ArrayList<>();
args.add(accountId);
args.add(java.sql.Date.valueOf(toDate));
if (fromDate != null) {
sql.append(" AND entry_date >= ?");
args.add(java.sql.Date.valueOf(fromDate));
}
if (officeId != null) {
sql.append(" AND office_id = ?");
args.add(officeId);
}
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
final BigDecimal debit = toDecimal(row.get("total_debit"));
final BigDecimal credit = toDecimal(row.get("total_credit"));
final boolean debitNatured = classification == ASSET || classification == EXPENSE;
return debitNatured ? debit.subtract(credit) : credit.subtract(debit);
}
/**
* A role's name as the return prints it, for the caveats. Falls back to the key so a role
* added without a name here degrades to something readable rather than to nothing.
*/
private static String lineName(final String roleKey) {
return LINE_NAMES.getOrDefault(roleKey, roleKey);
}
private static final Map<String, String> LINE_NAMES = new LinkedHashMap<>();
static {
LINE_NAMES.put("OSS1_PAID_UP_CAPITAL", "Paid-up Capital");
LINE_NAMES.put("OSS1_RESERVES", "Reserve Fund and other Reserves");
LINE_NAMES.put("OSS1_PROFIT_LOSS", "Profit / Loss");
LINE_NAMES.put("OSS1_SUBORDINATED_DEBT", "Subordinated Debt (Tier II Capital)");
LINE_NAMES.put("OSS1_DEPOSITS", "Deposits and other Accounts");
LINE_NAMES.put("OSS1_DEPOSITS_TERM", "Term Deposits");
LINE_NAMES.put("OSS1_DEPOSITS_SAVINGS", "Savings Bank Deposits");
LINE_NAMES.put("OSS1_DEPOSITS_CURRENT", "Current Deposits");
LINE_NAMES.put("OSS1_BORROWINGS", "Borrowings");
LINE_NAMES.put("OSS1_OTHER_LIABILITIES", "Other Liabilities and Provisions");
LINE_NAMES.put("OSS1_CASH_IN_HAND", "Cash in Hand");
LINE_NAMES.put("OSS1_BALANCE_WITH_BANKS", "Balances with Banks");
LINE_NAMES.put("OSS1_INVESTMENTS", "Investments");
LINE_NAMES.put("OSS1_LOANS_ADVANCES", "Loans and Advances");
LINE_NAMES.put("OSS1_FIXED_ASSETS", "Fixed Assets");
LINE_NAMES.put("OSS1_OTHER_ASSETS", "Other Assets");
LINE_NAMES.put("OSS2_INTEREST_ON_ADVANCES", "Interest on loans and advances");
LINE_NAMES.put("OSS2_INTEREST_ON_MARKET_LENDING", "Interest on market lending");
LINE_NAMES.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest on investments");
LINE_NAMES.put("OSS2_INTEREST_ON_BANK_BALANCES", "Interest on bank balances");
LINE_NAMES.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "Commission, Exchange and Brokerage");
LINE_NAMES.put("OSS2_PROFIT_ON_INVESTMENTS", "Profit on sale of investments");
LINE_NAMES.put("OSS2_OTHER_INCOME", "Other income");
LINE_NAMES.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid on deposits");
LINE_NAMES.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings");
LINE_NAMES.put("OSS2_STAFF_COST", "Salaries, allowances and provident fund");
LINE_NAMES.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting");
LINE_NAMES.put("OSS2_DEPRECIATION", "Depreciation");
LINE_NAMES.put("OSS2_PROVISIONS", "Provisions and contingencies");
LINE_NAMES.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses");
}
/** JDBC returns SUM() as whichever numeric type the driver picks; normalise it. */
private static BigDecimal toDecimal(final Object value) {
if (value == null) {
return BigDecimal.ZERO;
}
return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString());
}
/** Rupees to the thousands the return is denominated in. */
private static BigDecimal scaled(final BigDecimal rupees) {
return rupees.divide(RETURN_SCALE, 2, RoundingMode.HALF_UP);
}
// ─────────────────────────── headcounts ───────────────────────────
/**
* Depositors as at a date - distinct clients holding a deposit account open on that date.
* Counted per client rather than per account, because a customer with three accounts is one
* depositor on the return.
*
* An account is open on the date if it was active by then and had not yet closed. Reading it
* this way, rather than by today's status, is what lets an earlier quarter be reprinted and
* still report the headcount that was true then.
*/
private Long countDepositors(final Long officeId, final LocalDate asOn, final List<Integer> depositTypes) {
final StringBuilder sql = new StringBuilder("""
SELECT COUNT(DISTINCT sa.client_id)
FROM m_savings_account sa
JOIN m_client c ON c.id = sa.client_id
WHERE sa.status_enum = ?
AND sa.activatedon_date IS NOT NULL
AND sa.activatedon_date <= ?
AND (sa.closedon_date IS NULL OR sa.closedon_date > ?)
""");
final List<Object> args = new ArrayList<>();
args.add(SAVINGS_ACTIVE);
args.add(java.sql.Date.valueOf(asOn));
args.add(java.sql.Date.valueOf(asOn));
if (depositTypes != null && !depositTypes.isEmpty()) {
sql.append(" AND sa.deposit_type_enum IN (")
.append(String.join(", ", depositTypes.stream().map(t -> "?").toList())).append(")");
args.addAll(depositTypes);
}
if (officeId != null) {
sql.append(" AND c.office_id = ?");
args.add(officeId);
}
final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray());
return count == null ? 0L : count;
}
/**
* The financial year's result up to the quarter end: income earned less expenses incurred.
*
* Read across every income and expense account rather than the mapped ones, because this line
* exists to close the gap between the balance sheet and the books. A figure limited to mapped
* accounts would leave the return short by whatever was not mapped, which is the very problem
* the line is here to solve.
*
* The year starts on 1 April, as the Indian financial year does.
*/
private BigDecimal currentYearResult(final Quarter quarter, final Long officeId) {
final LocalDate yearStart = financialYearStart(quarter.end);
final StringBuilder sql = new StringBuilder("""
SELECT COALESCE(SUM(CASE WHEN a.classification_enum = ?
THEN CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END
ELSE CASE WHEN je.type_enum = 2 THEN -je.amount ELSE je.amount END
END), 0) AS result
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE a.classification_enum IN (?, ?)
AND je.entry_date >= ? AND je.entry_date <= ?
AND (je.reversed IS NULL OR je.reversed = false)
AND je.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""");
final List<Object> args = new ArrayList<>();
args.add(INCOME);
args.add(INCOME);
args.add(EXPENSE);
args.add(java.sql.Date.valueOf(yearStart));
args.add(java.sql.Date.valueOf(quarter.end));
if (officeId != null) {
sql.append(" AND je.office_id = ?");
args.add(officeId);
}
final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray());
return result == null ? BigDecimal.ZERO : result;
}
/** 1 April of the financial year a date falls in. */
private static LocalDate financialYearStart(final LocalDate date) {
final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return LocalDate.of(year, 4, 1);
}
/** Borrowers as at a date - distinct clients with a loan outstanding on that date. */
private Long borrowerCount(final Long officeId, final LocalDate asOn) {
final StringBuilder sql = new StringBuilder("""
SELECT COUNT(DISTINCT l.client_id)
FROM m_loan l
JOIN m_client c ON c.id = l.client_id
WHERE l.loan_status_id = ?
AND l.disbursedon_date IS NOT NULL
AND l.disbursedon_date <= ?
""");
final List<Object> args = new ArrayList<>();
args.add(LOAN_ACTIVE);
args.add(java.sql.Date.valueOf(asOn));
if (officeId != null) {
sql.append(" AND c.office_id = ?");
args.add(officeId);
}
final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray());
return count == null ? 0L : count;
}
// ─────────────────────────── line builders ───────────────────────────
private Map<String, Object> balanceLine(final String label, final String roleKey, final RoleFigures figures) {
return line(label, figures.closing(roleKey));
}
private Map<String, Object> movementLine(final String label, final String roleKey, final RoleFigures figures) {
return line(label, figures.movement(roleKey));
}
private static Map<String, Object> line(final String label, final RoleFigure figure) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", figure.roleKey);
out.put("configured", figure.configured);
// Null rather than zero when unmapped: the screen must be able to tell "nothing mapped"
// from "mapped and genuinely nil".
out.put("amount", figure.configured ? scaled(figure.amount) : null);
out.put("accounts", figure.accounts);
out.put("subtotal", false);
return out;
}
/**
* A figure the return computes for itself rather than reading from mapped accounts.
*
* It carries a role key so the subtotals can name it like any other component, but that key is
* not a configurable role: nothing can leave it unmapped, and nothing can map it wrong.
*/
private static Map<String, Object> derivedLine(final String label, final String roleKey,
final BigDecimal rupees) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", roleKey);
out.put("configured", true);
out.put("amount", scaled(rupees));
out.put("accounts", List.of());
out.put("derived", true);
out.put("subtotal", false);
return out;
}
private static Map<String, Object> countLine(final String label, final Long count) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", null);
out.put("configured", true);
out.put("amount", count);
out.put("accounts", List.of());
out.put("count", true);
out.put("subtotal", false);
return out;
}
/**
* A derived row, summed from the roles it names. Subtotals are never mapped to accounts of
* their own - a total and its components mapped separately would disagree the moment one side
* changed - and they name their components explicitly rather than summing whatever happens to
* sit above them, so inserting a line cannot silently change a total.
*/
private static Map<String, Object> subtotal(final String label, final List<Map<String, Object>> section,
final String... ofRoles) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("roleKey", null);
out.put("configured", true);
out.put("amount", sumOfRoles(section, ofRoles));
out.put("accounts", List.of());
out.put("components", List.of(ofRoles));
out.put("subtotal", true);
return out;
}
/** The amount already computed for a labelled row, so a total is never derived twice. */
private static BigDecimal amountOf(final List<Map<String, Object>> section, final String label) {
for (final Map<String, Object> line : section) {
if (label.equals(line.get("label")) && line.get("amount") instanceof BigDecimal decimal) {
return decimal;
}
}
return BigDecimal.ZERO;
}
/**
* Sums the named roles out of a section.
*
* A component that is unmapped contributes nothing, which is why the return still reports
* {@code unmetRequirements}: the total is arithmetically right for what is mapped, and short by
* whatever is not.
*/
private static BigDecimal sumOfRoles(final List<Map<String, Object>> section, final String... roles) {
final Set<String> wanted = new LinkedHashSet<>(Arrays.asList(roles));
BigDecimal total = BigDecimal.ZERO;
for (final Map<String, Object> line : section) {
// Subtotals and headcounts carry no role of their own, so their key is null. Matched by
// the role rather than by a null-tolerant lookup, so a derived row can never be summed
// into the total that derives it.
final Object roleKey = line.get("roleKey");
if (roleKey == null || !wanted.contains(roleKey.toString())) {
continue;
}
if (line.get("amount") instanceof BigDecimal decimal) {
total = total.add(decimal);
}
}
return total;
}
private static LocalDate parseOrBusinessDate(final String param) {
return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param);
}
}
@@ -0,0 +1,687 @@
/**
* 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.rbireserve.api;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Admin screen backing API for RBI Form 1 (CRR) and Form 9 (SLR) configuration - the CRR/SLR
* percentages and the GL accounts the returns read their figures from.
*
* Both halves live on one screen because they are one decision: a percentage is meaningless without
* knowing which accounts it applies to, and an administrator who sets one and forgets the other
* produces a return that looks complete and is wrong.
*
* Roles are fixed (see {@link #ROLE_DESCRIPTIONS}); accounts are pinned to them. Unlike
* m_clearing_gl_config, a role takes MANY accounts - a bank holds current accounts at a dozen other
* banks and each counts toward the balances it maintains.
*/
@Path("/v1/rbireserveconfig")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class RbiReserveConfigApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG";
/**
* The roles a return can read, in the order the screen presents them. Held here rather than
* seeded as rows because they are structural - a role only exists if code computes something
* from it, so adding one is a code change either way.
*/
private static final Map<String, String> ROLE_DESCRIPTIONS = new LinkedHashMap<>();
static {
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS",
"Current account balances the bank holds with other banks. Counts toward CRR maintained. "
+ "Pin every such account - a bank typically banks with several.");
ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES",
"Deposit liabilities forming the DTL base that CRR and SLR are charged on. "
+ "Pin the deposit accounts and any interest accrued on them.");
ROLE_DESCRIPTIONS.put("SLR_INVESTMENTS",
"SLR-eligible investments - central and state government securities. "
+ "Counts toward SLR maintained on Form 9. Exclude anything marked non-SLR.");
// OSS 1 - Return on Assets and Liabilities. The balance-sheet heads, each read as its
// closing balance at the quarter end.
ROLE_DESCRIPTIONS.put("OSS1_PAID_UP_CAPITAL",
"Share capital paid up by members. OSS 1 liability head 1.");
ROLE_DESCRIPTIONS.put("OSS1_RESERVES",
"Reserve fund and other reserves - statutory, building, dividend equalisation, "
+ "provisions for standard assets. OSS 1 liability head 2.");
ROLE_DESCRIPTIONS.put("OSS1_PROFIT_LOSS",
"Accumulated profit or loss carried on the balance sheet. OSS 1 liability head 3.");
ROLE_DESCRIPTIONS.put("OSS1_SUBORDINATED_DEBT",
"Subordinated debt counted as Tier II capital. OSS 1 liability head 5.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS",
"All deposit liabilities. OSS 1 liability head 6. Pin the deposit control accounts; "
+ "the term/savings/current heads below break this same total down and are not added to it.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_TERM",
"Term deposits, including recurring deposits and matured term deposits. "
+ "A breakdown of OSS1_DEPOSITS, not an addition to it.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_SAVINGS",
"Savings bank deposits. A breakdown of OSS1_DEPOSITS, not an addition to it.");
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_CURRENT",
"Current account deposits. A breakdown of OSS1_DEPOSITS, not an addition to it.");
ROLE_DESCRIPTIONS.put("OSS1_BORROWINGS",
"Borrowings from banks and other institutions. OSS 1 liability head 7.");
ROLE_DESCRIPTIONS.put("OSS1_OTHER_LIABILITIES",
"Other liabilities and provisions - bills payable, interest accrued, sundry creditors.");
ROLE_DESCRIPTIONS.put("OSS1_CASH_IN_HAND",
"Cash in hand on the asset side of OSS 1. Usually the same accounts as CASH_IN_HAND.");
ROLE_DESCRIPTIONS.put("OSS1_BALANCE_WITH_BANKS",
"Balances with banks - current, savings and fixed. "
+ "Usually the same accounts as BALANCE_WITH_OTHER_BANKS, plus any fixed placements.");
ROLE_DESCRIPTIONS.put("OSS1_INVESTMENTS",
"All investments, SLR and non-SLR together. OSS 1 asset head 3.");
ROLE_DESCRIPTIONS.put("OSS1_LOANS_ADVANCES",
"Loans and advances to members and others. OSS 1 asset head 4.");
ROLE_DESCRIPTIONS.put("OSS1_FIXED_ASSETS",
"Premises, furniture and other fixed assets, net of depreciation.");
ROLE_DESCRIPTIONS.put("OSS1_OTHER_ASSETS", "Other assets - interest receivable, sundry debtors, stationery.");
// OSS 2 - Return on Earnings. These read the movement over the quarter, not a balance: an
// earnings line reports what was earned or spent in the period.
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_ADVANCES",
"Interest and discount earned on loans and advances. OSS 2 item 2.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_MARKET_LENDING",
"Interest on lending to banks - call and short notice money. OSS 2 item 3.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest earned on investments. OSS 2 item 4.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_BANK_BALANCES",
"Interest on balances with RBI, inter-bank deposits and placements. OSS 2 item 5.");
ROLE_DESCRIPTIONS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE",
"Commission, exchange and brokerage earned. OSS 2 item 6.");
ROLE_DESCRIPTIONS.put("OSS2_PROFIT_ON_INVESTMENTS",
"Profit on sale of investments. Pin the income account only; losses belong under provisions.");
ROLE_DESCRIPTIONS.put("OSS2_OTHER_INCOME", "Other income not covered by the heads above.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid or accrued on deposits. OSS 2 item 10.");
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings. OSS 2 item 11.");
ROLE_DESCRIPTIONS.put("OSS2_STAFF_COST",
"Salaries, allowances, provident fund and other staff cost. OSS 2 item 12.");
ROLE_DESCRIPTIONS.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting. OSS 2 item 13.");
ROLE_DESCRIPTIONS.put("OSS2_DEPRECIATION", "Depreciation on the bank's own property. OSS 2 item 14.");
ROLE_DESCRIPTIONS.put("OSS2_PROVISIONS",
"Provisions and contingencies, including bad debt provision and loss on investments.");
ROLE_DESCRIPTIONS.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses. OSS 2 item 16.");
}
/**
* A readable name per role. Held next to the descriptions rather than in the screen, so a role
* added here cannot show up in the UI as a raw key nobody wrote a label for.
*/
private static final Map<String, String> ROLE_LABELS = new LinkedHashMap<>();
static {
ROLE_LABELS.put("CASH_IN_HAND", "Cash in hand");
ROLE_LABELS.put("BALANCE_WITH_OTHER_BANKS", "Balances with other banks");
ROLE_LABELS.put("DEMAND_TIME_LIABILITIES", "Demand and Time Liabilities (DTL)");
ROLE_LABELS.put("SLR_INVESTMENTS", "SLR-eligible investments");
ROLE_LABELS.put("OSS1_PAID_UP_CAPITAL", "1. Paid-up Capital");
ROLE_LABELS.put("OSS1_RESERVES", "2. Reserve Fund and other Reserves");
ROLE_LABELS.put("OSS1_PROFIT_LOSS", "3. Profit / Loss");
ROLE_LABELS.put("OSS1_SUBORDINATED_DEBT", "5. Subordinated Debt (Tier II)");
ROLE_LABELS.put("OSS1_DEPOSITS", "6. Deposits and other Accounts");
ROLE_LABELS.put("OSS1_DEPOSITS_TERM", "6 i. Term Deposits");
ROLE_LABELS.put("OSS1_DEPOSITS_SAVINGS", "6 ii. Savings Bank Deposits");
ROLE_LABELS.put("OSS1_DEPOSITS_CURRENT", "6 iii. Current Deposits");
ROLE_LABELS.put("OSS1_BORROWINGS", "7. Borrowings");
ROLE_LABELS.put("OSS1_OTHER_LIABILITIES", "8. Other Liabilities and Provisions");
ROLE_LABELS.put("OSS1_CASH_IN_HAND", "Assets 1. Cash in Hand");
ROLE_LABELS.put("OSS1_BALANCE_WITH_BANKS", "Assets 2. Balances with Banks");
ROLE_LABELS.put("OSS1_INVESTMENTS", "Assets 3. Investments");
ROLE_LABELS.put("OSS1_LOANS_ADVANCES", "Assets 4. Loans and Advances");
ROLE_LABELS.put("OSS1_FIXED_ASSETS", "Assets 5. Fixed Assets");
ROLE_LABELS.put("OSS1_OTHER_ASSETS", "Assets 6. Other Assets");
ROLE_LABELS.put("OSS2_INTEREST_ON_ADVANCES", "2. Interest on loans and advances");
ROLE_LABELS.put("OSS2_INTEREST_ON_MARKET_LENDING", "3. Interest on market lending");
ROLE_LABELS.put("OSS2_INTEREST_ON_INVESTMENTS", "4. Interest on investments");
ROLE_LABELS.put("OSS2_INTEREST_ON_BANK_BALANCES", "5. Interest on bank balances");
ROLE_LABELS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "6. Commission, Exchange and Brokerage");
ROLE_LABELS.put("OSS2_PROFIT_ON_INVESTMENTS", "7. Profit on sale of investments");
ROLE_LABELS.put("OSS2_OTHER_INCOME", "8. Other income");
ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "10. Interest paid on deposits");
ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "11. Interest paid on borrowings");
ROLE_LABELS.put("OSS2_STAFF_COST", "12. Salaries, allowances and PF");
ROLE_LABELS.put("OSS2_RENT_TAXES_INSURANCE", "13. Rent, taxes, insurance and lighting");
ROLE_LABELS.put("OSS2_DEPRECIATION", "14. Depreciation");
ROLE_LABELS.put("OSS2_PROVISIONS", "15. Provisions and contingencies");
ROLE_LABELS.put("OSS2_OTHER_OPERATING_EXPENSES", "16. Other operating expenses");
}
/**
* Which return each role belongs to, and how it is used there, so the screen can separate a
* long list into the forms it affects and state the consequence of leaving one unmapped.
*/
private static final Map<String, String> ROLE_GROUPS = new LinkedHashMap<>();
private static final Map<String, String> ROLE_GROUP_LABELS = new LinkedHashMap<>();
private static final Map<String, String> ROLE_USAGE = new LinkedHashMap<>();
static {
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
if (role.startsWith("OSS1_")) {
ROLE_GROUPS.put(role, "OSS1");
ROLE_USAGE.put(role, "OSS 1 (RAL) — closing balance at quarter end");
} else if (role.startsWith("OSS2_")) {
ROLE_GROUPS.put(role, "OSS2");
ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter");
} else {
ROLE_GROUPS.put(role, "RESERVE");
if ("SLR_INVESTMENTS".equals(role)) {
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
} else {
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
}
}
}
}
/**
* SAVINGS_DEMAND is the share of savings balances RBI treats as a demand liability. It sits
* beside CRR and SLR because it behaves like them: set by RBI, changed by circular, and
* effective-dated so a refiled return reproduces the split that was in force.
*/
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
/** The four treatments a head can be given, each naming a branch the returns already have. */
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO");
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
static {
USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head");
USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL");
USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward what is maintained against the requirement");
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
}
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
static {
BUILT_IN_USAGE.put("DEMAND_TIME_LIABILITIES", "LIABILITY");
BUILT_IN_USAGE.put("BALANCE_WITH_OTHER_BANKS", "MAINTAINED");
BUILT_IN_USAGE.put("CASH_IN_HAND", "MAINTAINED");
BUILT_IN_USAGE.put("SLR_INVESTMENTS", "MAINTAINED");
}
/** Roles whose accounts carry a Demand/Time class. */
private static final java.util.Set<String> LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES");
private static final String DEMAND = "DEMAND";
private static final String TIME = "TIME";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
// ─────────────────────────── read ───────────────────────────
@GET
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rates", rates());
out.put("glRoles", glRoles());
return gson.toJson(out);
}
@GET
@Path("template")
public String template() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
// Header accounts are excluded deliberately. A header carries no journal entry of its own -
// its balance is the sum of its children - so pinning both a header and any child would
// double-count that child. Only postable detail accounts are offered.
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT id, name, gl_code, classification_enum
FROM acc_gl_account
WHERE disabled = false AND account_usage = 1
ORDER BY gl_code
""");
final List<Map<String, Object>> options = new ArrayList<>();
for (final Map<String, Object> a : accounts) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("id", a.get("id"));
o.put("name", a.get("name"));
o.put("glCode", a.get("gl_code"));
o.put("classification", a.get("classification_enum"));
options.add(o);
}
return gson.toJson(Map.of("glAccountOptions", options, "rateTypes", RATE_TYPES));
}
private List<Map<String, Object>> rates() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, rate_type, percentage, effective_from, is_seed_default
FROM m_rbi_reserve_rate
ORDER BY rate_type, effective_from DESC
""");
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("id", row.get("id"));
o.put("rateType", row.get("rate_type"));
o.put("percentage", row.get("percentage"));
o.put("effectiveFrom", String.valueOf(row.get("effective_from")));
// A placeholder shipped with the migration, not a figure this bank confirmed.
o.put("seedDefault", row.get("is_seed_default"));
out.add(o);
}
return out;
}
/** Every role, including those with nothing pinned - the screen must show the gaps. */
private List<Map<String, Object>> glRoles() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id, c.config_key, c.gl_account_id, c.weight_percentage, c.liability_class,
a.name AS gl_account_name, a.gl_code
FROM m_rbi_reserve_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
ORDER BY c.config_key, a.gl_code, c.liability_class
""");
// Seeded with every key the configuration knows, built in or added, so a mapping against a
// head an administrator created lands in a bucket rather than being dropped as unknown.
final Map<String, List<Map<String, Object>>> byKey = new LinkedHashMap<>();
for (final String key : knownRoleKeys()) {
byKey.put(key, new ArrayList<>());
}
for (final Map<String, Object> row : rows) {
final String key = (String) row.get("config_key");
// A row whose key is no longer a known role is ignored rather than surfaced - it cannot
// affect any figure, because nothing reads that key.
final List<Map<String, Object>> bucket = byKey.get(key);
if (bucket == null) {
continue;
}
final Map<String, Object> account = new LinkedHashMap<>();
account.put("id", row.get("id"));
account.put("glAccountId", row.get("gl_account_id"));
account.put("glAccountName", row.get("gl_account_name"));
account.put("glCode", row.get("gl_code"));
// The portion of the balance this mapping takes, and which head it falls under. Sent
// back so the screen edits what is stored rather than re-deriving a default that would
// overwrite a deliberate split on the next save.
account.put("weightPercentage", row.get("weight_percentage"));
account.put("liabilityClass", row.get("liability_class"));
bucket.add(account);
}
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map.Entry<String, String> role : ROLE_DESCRIPTIONS.entrySet()) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("configKey", role.getKey());
o.put("description", role.getValue());
o.put("label", ROLE_LABELS.getOrDefault(role.getKey(), role.getKey()));
// Which return reads this role, so the screen can group a long list by the form it
// affects rather than presenting every role as equally relevant to every return.
final String group = ROLE_GROUPS.get(role.getKey());
o.put("group", group);
o.put("groupLabel", ROLE_GROUP_LABELS.get(group));
o.put("usedBy", ROLE_USAGE.get(role.getKey()));
// Built-in roles are wired into the arithmetic by name, so they cannot be renamed or
// removed - the screen greys those controls rather than offering an action that would
// have to fail.
o.put("builtIn", true);
o.put("usage", BUILT_IN_USAGE.getOrDefault(role.getKey(), "MEMO"));
o.put("classified", LIABILITY_ROLES.contains(role.getKey()));
o.put("accounts", byKey.get(role.getKey()));
out.add(o);
}
// Heads an administrator added. Appended after the built-ins so the structural roles keep
// their fixed order at the top of each group regardless of what was added later.
for (final Map<String, Object> row : customRoleRows()) {
final String key = (String) row.get("config_key");
final String group = (String) row.get("role_group");
final String usage = (String) row.get("role_usage");
final Map<String, Object> o = new LinkedHashMap<>();
o.put("configKey", key);
o.put("description", row.get("description"));
o.put("label", row.get("label"));
o.put("group", group);
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
o.put("builtIn", false);
o.put("usage", usage);
// A liability head carries the demand/time split; nothing else does.
o.put("classified", "LIABILITY".equals(usage));
o.put("roleId", row.get("id"));
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
out.add(o);
}
return out;
}
/** Administrator-defined heads, in the order the screen presents them. */
private List<Map<String, Object>> customRoleRows() {
return jdbcTemplate.queryForList("""
SELECT id, config_key, label, description, role_group, role_usage, position
FROM m_rbi_reserve_role
ORDER BY role_group, position, label
""");
}
/** Every role key the configuration currently knows - built in or added. */
private java.util.Set<String> knownRoleKeys() {
final java.util.Set<String> keys = new java.util.LinkedHashSet<>(ROLE_DESCRIPTIONS.keySet());
for (final Map<String, Object> row : customRoleRows()) {
keys.add((String) row.get("config_key"));
}
return keys;
}
// ─────────────────────────── write ───────────────────────────
/**
* Replaces the whole set of accounts pinned to one role. Sending the full set rather than
* add/remove deltas keeps the screen and the stored configuration in step even if two
* administrators have the page open at once - the last save wins wholesale, instead of the two
* interleaving into a set neither of them chose.
*/
@PUT
@Path("gl/{configKey}")
@Consumes({ MediaType.APPLICATION_JSON })
public String updateGlRole(@PathParam("configKey") final String configKey, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
if (!knownRoleKeys().contains(configKey)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.role",
"'" + configKey + "' is not a known RBI reserve configuration role.");
}
final boolean classified = isClassifiedRole(configKey);
final JsonElement root = this.fromJsonHelper.parse(body);
// Two shapes are accepted. "mappings" carries a weight and a class per account and is what
// the screen sends; "glAccountIds" is the flat list the first version of this screen sent,
// still honoured so an older client keeps working - its accounts are taken whole, which is
// exactly what they meant before weights existed.
final List<Mapping> mappings = new ArrayList<>();
final com.google.gson.JsonArray rows = root.getAsJsonObject().has("mappings")
? root.getAsJsonObject().getAsJsonArray("mappings")
: null;
if (rows != null) {
for (final JsonElement element : rows) {
final com.google.gson.JsonObject row = element.getAsJsonObject();
final Long accountId = row.get("glAccountId").getAsLong();
final BigDecimal weight = row.has("weightPercentage") && !row.get("weightPercentage").isJsonNull()
? row.get("weightPercentage").getAsBigDecimal()
: BigDecimal.valueOf(100);
final String liabilityClass = classified && row.has("liabilityClass")
&& !row.get("liabilityClass").isJsonNull() ? row.get("liabilityClass").getAsString() : null;
mappings.add(new Mapping(accountId, weight, liabilityClass));
}
} else {
final String[] ids = this.fromJsonHelper.extractArrayNamed("glAccountIds", root);
if (ids != null) {
for (final String id : ids) {
mappings.add(new Mapping(Long.valueOf(id), BigDecimal.valueOf(100), null));
}
}
}
final java.util.Set<String> seen = new java.util.LinkedHashSet<>();
for (final Mapping mapping : mappings) {
final Integer exists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND disabled = false AND account_usage = 1",
Integer.class, mapping.accountId);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.gl.account.not.found",
"GL account " + mapping.accountId + " was not found, is disabled, or is a header account.");
}
// A weight outside 0-100 is not a split of a balance. Above 100 it would report more
// than the account holds; below zero it would subtract a liability from the base.
if (mapping.weight == null || mapping.weight.compareTo(BigDecimal.ZERO) < 0
|| mapping.weight.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.weight.out.of.range",
"The portion taken from an account must be between 0 and 100 percent.");
}
if (mapping.liabilityClass != null && !DEMAND.equals(mapping.liabilityClass)
&& !TIME.equals(mapping.liabilityClass)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.liability.class",
"A liability must be classified as " + DEMAND + " or " + TIME + ".");
}
// The same account may appear twice under one role only if the two rows are different
// heads - a demand slice and a time slice. Twice under the same head is a double count.
if (!seen.add(mapping.accountId + "/" + mapping.liabilityClass)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.duplicate.mapping",
"GL account " + mapping.accountId
+ " is mapped to this role twice under the same head; it would be counted twice.");
}
}
jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", configKey);
for (final Mapping mapping : mappings) {
jdbcTemplate.update("""
INSERT INTO m_rbi_reserve_gl_config
(config_key, gl_account_id, weight_percentage, liability_class, created_on)
VALUES (?, ?, ?, ?, now())
""", configKey, mapping.accountId, mapping.weight, mapping.liabilityClass);
}
return retrieveAll();
}
/** One account pinned to a role: which account, how much of it, and under which head. */
private record Mapping(Long accountId, BigDecimal weight, String liabilityClass) {}
/** Whether a role's accounts carry a Demand/Time class - built in or administrator-defined. */
private boolean isClassifiedRole(final String configKey) {
if (LIABILITY_ROLES.contains(configKey)) {
return true;
}
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT role_usage FROM m_rbi_reserve_role WHERE config_key = ?", configKey);
return !found.isEmpty() && "LIABILITY".equals(found.get(0).get("role_usage"));
}
/**
* Adds a head to a return - a reserve, a tax payable, a bills-payable account, anything the
* bank carries that the built-in roles do not name.
*
* The head needs no new code because it declares how it is consumed: a LIABILITY head enters the
* base under its demand or time class, an ASSET_NET head is netted off it, a MAINTAINED head
* counts toward the requirement, a MEMO head is shown and totals nothing. Each of those is a
* branch the returns already have.
*/
@POST
@Path("role")
@Consumes({ MediaType.APPLICATION_JSON })
public String createRole(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String label = this.fromJsonHelper.extractStringNamed("label", root);
final String description = this.fromJsonHelper.extractStringNamed("description", root);
final String group = this.fromJsonHelper.extractStringNamed("group", root);
final String usage = this.fromJsonHelper.extractStringNamed("usage", root);
if (label == null || label.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.no.label",
"A name is required for the head.");
}
if (group == null || !ROLE_GROUP_LABELS.containsKey(group)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.group",
"The head must belong to one of " + ROLE_GROUP_LABELS.keySet() + ".");
}
if (usage == null || !ROLE_USAGES.contains(usage)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.usage",
"How the head is used must be one of " + ROLE_USAGES + ".");
}
// The key is derived from the name rather than typed, so an administrator naming a head does
// not also have to invent a stable identifier - and cannot collide with a built-in role by
// choosing one of its names.
final String base = "CUSTOM_" + label.toUpperCase(java.util.Locale.ROOT).replaceAll("[^A-Z0-9]+", "_")
.replaceAll("^_|_$", "");
String key = base.length() > 50 ? base.substring(0, 50) : base;
int suffix = 2;
while (knownRoleKeys().contains(key)) {
final String candidate = base + "_" + suffix++;
key = candidate.length() > 50 ? candidate.substring(0, 50) : candidate;
}
jdbcTemplate.update("""
INSERT INTO m_rbi_reserve_role
(config_key, label, description, role_group, role_usage, position, created_on, created_by)
VALUES (?, ?, ?, ?, ?, ?, now(), ?)
""", key, label.trim(), description, group, usage, nextPosition(group),
this.context.authenticatedUser().getId());
return retrieveAll();
}
/** Where a new head sits in its group: after the ones already there. */
private int nextPosition(final String group) {
final Integer max = jdbcTemplate.queryForObject(
"SELECT COALESCE(MAX(position), 100) FROM m_rbi_reserve_role WHERE role_group = ?", Integer.class,
group);
return (max == null ? 100 : max) + 1;
}
/**
* Removes an administrator-defined head, together with the accounts pinned to it.
*
* Built-in roles are refused: the returns reference them by name, so a deleted one would not
* leave a smaller return but a broken computation.
*/
@DELETE
@Path("role/{roleId}")
public String deleteRole(@PathParam("roleId") final Long roleId) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT config_key, label FROM m_rbi_reserve_role WHERE id = ?", roleId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.not.found",
"Head " + roleId + " was not found, or is a built-in role that cannot be removed.");
}
final String key = (String) found.get(0).get("config_key");
// The mappings go with it. Left behind they would reference a head nothing reads, and would
// silently reappear under a later head that happened to derive the same key.
jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", key);
jdbcTemplate.update("DELETE FROM m_rbi_reserve_role WHERE id = ?", roleId);
return retrieveAll();
}
/**
* Sets a rate with effect from a date. Restating a rate for a date it already carries edits that
* row; a new date adds one. Earlier rows are never rewritten, so a return filed for a past
* fortnight keeps reporting the rate that was actually in force then.
*/
@POST
@Path("rate")
@Consumes({ MediaType.APPLICATION_JSON })
public String upsertRate(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String rateType = this.fromJsonHelper.extractStringNamed("rateType", root);
final BigDecimal percentage = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("percentage", root);
final LocalDate effectiveFrom = this.fromJsonHelper.extractLocalDateNamed("effectiveFrom", root);
if (rateType == null || !RATE_TYPES.contains(rateType)) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.rate.type",
"Rate type must be one of " + RATE_TYPES + ".");
}
if (percentage == null || percentage.compareTo(BigDecimal.ZERO) < 0
|| percentage.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.out.of.range",
"Percentage must be between 0 and 100.");
}
if (effectiveFrom == null) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.no.effective.date",
"An effective-from date is required.");
}
final Integer exists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ? AND effective_from = ?", Integer.class,
rateType, java.sql.Date.valueOf(effectiveFrom));
if (exists != null && exists > 0) {
// Saving over a seeded placeholder makes it this bank's own figure, so the flag clears
// and the returns stop captioning it as a default.
jdbcTemplate.update("""
UPDATE m_rbi_reserve_rate SET percentage = ?, is_seed_default = false
WHERE rate_type = ? AND effective_from = ?
""", percentage, rateType, java.sql.Date.valueOf(effectiveFrom));
} else {
jdbcTemplate.update("""
INSERT INTO m_rbi_reserve_rate (rate_type, percentage, effective_from, created_on, created_by,
is_seed_default)
VALUES (?, ?, ?, now(), ?, false)
""", rateType, percentage, java.sql.Date.valueOf(effectiveFrom),
this.context.authenticatedUser().getId());
}
return retrieveAll();
}
/**
* Removes one effective-dated rate row. The last remaining row for a type is kept: with none at
* all, every return of that type would stop computing, which is a harsher outcome than an
* administrator deleting the wrong row expects.
*/
@DELETE
@Path("rate/{rateId}")
public String deleteRate(@PathParam("rateId") final Long rateId) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT rate_type FROM m_rbi_reserve_rate WHERE id = ?", rateId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.not.found",
"Rate " + rateId + " was not found.");
}
final String rateType = (String) found.get(0).get("rate_type");
final Integer remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ?",
Integer.class, rateType);
if (remaining != null && remaining <= 1) {
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.last.row",
"The only " + rateType + " rate on record cannot be removed - edit it instead.");
}
jdbcTemplate.update("DELETE FROM m_rbi_reserve_rate WHERE id = ?", rateId);
return retrieveAll();
}
}
@@ -0,0 +1,892 @@
/**
* 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.rbireserve.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Live computation of RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly).
*
* <h3>What the returns say</h3>
*
* Both returns answer the same shape of question: against a liability base (Demand and Time
* Liabilities), a percentage set by RBI must be maintained in a defined class of assets. Form 1
* maintains CRR in cash and balances with banks; Form 9 maintains SLR in government securities.
*
* <h3>The lag basis, which is the part worth reading twice</h3>
*
* The base and the holdings are NOT read as at the same date. RBI reckons the requirement on the
* DTL of an earlier reporting Friday and compares it against what is actually held now - that lag
* is what gives a bank time to arrange its position. So:
*
* <pre>
* DTL as at the reporting Friday on or before (reporting date - 45 days)
* Required = DTL x rate in force
* Held as at the reporting date itself
* Surplus = Held - Required
* </pre>
*
* The 45 days come from the reporting requirement; snapping back to a Friday is what makes it a
* reporting basis rather than an arbitrary calendar day, since a Sunday has no close of business to
* reckon a balance at. Both dates are returned so the basis is auditable rather than implied - a
* reviewer can see the raw 45-day date and the Friday actually used.
*
* <h3>Reading balances</h3>
*
* Balances are read the way every other report in this codebase reads them (see
* BalanceSheetApiResource, ProfitAndLossApiResource): on entry_date, excluding reversed entries AND
* the counter entries that Fineract's own revertJournalEntry posts against them. Dropping only the
* original leg of that second kind would leave its counter entry standing alone and swing the
* balance by the full amount in the wrong direction.
*
* <h3>When something is not configured</h3>
*
* A role with no GL accounts pinned reports as unconfigured, and the return refuses to present a
* total that silently treats it as zero. A CRR return that reports zero holdings because nobody
* mapped the cash account is worse than one that says it cannot compute.
*/
@Path("/v1/rbireservereturn")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class RbiReserveReturnApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG";
/** Days the DTL base lags the reporting date, before snapping to a reporting Friday. */
private static final int DTL_LAG_DAYS = 45;
/**
* The share of the requirement that must be held on EVERY day of the fortnight, not merely on
* average across it. RBI sets this daily floor precisely so a bank cannot run the fortnight
* near-empty and make the average up on the closing day.
*/
private static final BigDecimal DAILY_MINIMUM_PERCENT = BigDecimal.valueOf(90);
private static final int ASSET = 1;
private static final int EXPENSE = 5;
/** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */
private static final int POSTABLE_ACCOUNT = 1;
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
/** The two heads liabilities are reported under. */
private static final String DEMAND = "DEMAND";
private static final String TIME = "TIME";
/** Roles where the demand/time split applies. Elsewhere an unclassified row is not a gap. */
private static final java.util.Set<String> LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES");
/** How an administrator-defined head is consumed; see m_rbi_reserve_role.role_usage. */
private static final String USAGE_LIABILITY = "LIABILITY";
private static final String USAGE_ASSET_NET = "ASSET_NET";
private static final String USAGE_MAINTAINED = "MAINTAINED";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
/**
* Form 1 - CRR, filed twice a month.
*
* @param reportingDateParam
* defaults to the current business date.
*/
@GET
@Path("form1")
public String form1(@QueryParam("reportingDate") final String reportingDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate reportingDate = parseOrBusinessDate(reportingDateParam);
final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS);
// A reporting Friday that falls on a holiday has no close of business to strike a balance
// at, so the position is taken at the preceding working day instead.
final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate);
final LocalDate dtlDate = precedingWorkingDay(nominalDtlDate);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("formCode", "FORM_1");
response.put("formName", "Form 1 - Cash Reserve Ratio (CRR)");
response.put("frequency", "Fortnightly");
response.put("reportingDate", reportingDate.toString());
response.putAll(fortnightOf(reportingDate));
// The lag basis, spelled out so a reviewer can check it rather than trust it.
final boolean shifted = !dtlDate.equals(nominalDtlDate);
final Map<String, Object> basis = new LinkedHashMap<>();
basis.put("lagDays", DTL_LAG_DAYS);
basis.put("rawLagDate", rawLagDate.toString());
basis.put("reportingFriday", nominalDtlDate.toString());
basis.put("dtlAsOnDate", dtlDate.toString());
basis.put("holdingsAsOnDate", reportingDate.toString());
basis.put("shiftedForHoliday", shifted);
basis.put("note", "DTL is taken as at the reporting Friday on or before " + rawLagDate + " ("
+ DTL_LAG_DAYS + " days before the reporting date)"
+ (shifted
? "; that Friday (" + nominalDtlDate + ") was a holiday, so the position is taken at the "
+ "preceding working day " + dtlDate
: "")
+ "; balances maintained are taken as at " + reportingDate + ".");
response.put("basis", basis);
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId);
final RoleFigure cash = figureFor("CASH_IN_HAND", reportingDate, officeId);
final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", reportingDate, officeId);
final Rate rate = rateInForce("CRR", reportingDate);
response.put("rateType", "CRR");
response.put("ratePercentage", rate == null ? null : rate.percentage);
response.put("rateIsSeedDefault", rate != null && rate.seedDefault);
// Inter-bank assets, read on the DTL date rather than the reporting date: they are netted
// off the liability base, so they belong to the same close of business the base is struck
// at. Read on the reporting date they would net a later position against an earlier base.
final RoleFigure interBankAtDtlDate = figureFor("BALANCE_WITH_OTHER_BANKS", dtlDate, officeId);
// Heads the bank added itself - a reserve, a tax payable, anything its chart carries that
// the four built-in roles do not name. Each declares how it is consumed, so they fold into
// the same three totals below without the arithmetic knowing what any of them is called.
final List<CustomHead> heads = customHeads("RESERVE", dtlDate, reportingDate, officeId);
final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY));
final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true));
final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false));
final BigDecimal netted = interBankAtDtlDate.amount.add(totalOf(heads, USAGE_ASSET_NET));
// NDTL = DTL - inter-bank assets. CRR and SLR are charged on the NET figure; charging the
// gross would overstate the requirement by the rate times these balances.
//
// Floored at zero. The subtraction can go negative where inter-bank assets exceed the
// liabilities, and a negative base yields a negative required amount - which would make any
// holding at all, including none, report as a surplus. There is no negative reserve
// requirement: below zero the requirement is nil.
final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO);
final List<Map<String, Object>> lines = new ArrayList<>();
lines.add(line("Demand and Time Liabilities (DTL)", dtl, dtlDate));
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage)) {
lines.add(line(head.label, head.figure, dtlDate));
}
}
// The two heads the liability total is built from, each stated on the face of the return as
// RBI reports them rather than left implicit inside the total above.
lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate));
lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate));
lines.add(line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate));
for (final CustomHead head : heads) {
if (USAGE_ASSET_NET.equals(head.usage)) {
lines.add(line("Less: " + head.label, head.figure, dtlDate));
}
}
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate));
lines.add(line("Cash in hand — counts toward CRR maintained", cash, reportingDate));
lines.add(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate));
for (final CustomHead head : heads) {
// A head belonging to the Memorandum or an Annexure prints under that section's own
// heading below, not here: listing it in both places would show one figure twice and
// invite a reader to add it in.
if (inNamedSection(head)) {
continue;
}
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) {
lines.add(line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure,
reportingDate));
}
}
response.put("lines", lines);
final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured);
response.put("computable", computable);
if (computable) {
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
final BigDecimal held = cash.amount.add(banks.amount).add(totalOf(heads, USAGE_MAINTAINED));
response.put("dtlAmount", liabilities);
response.put("demandLiabilities", dtl.configured ? demand : null);
response.put("timeLiabilities", dtl.configured ? time : null);
response.put("interBankAssets", netted);
response.put("ndtlAmount", ndtl);
response.put("requiredAmount", required);
response.put("maintainedAmount", held);
response.put("surplusOrShortfall", held.subtract(required));
response.put("compliant", held.compareTo(required) >= 0);
// What the bank actually holds, as a percentage of the same base the requirement uses -
// directly comparable with the rate above.
response.put("maintainedPercentage", percentageOf(held, ndtl));
// Compliance over the fortnight, not on one day. RBI requires the full requirement on
// average across the fortnight AND a daily minimum on every single day of it, so a bank
// that held nothing for thirteen days and topped up on the last one fails even though
// its closing position looks ample.
final LocalDate fortnightStart = LocalDate.parse((String) response.get("fortnightStart"));
final LocalDate fortnightEnd = LocalDate.parse((String) response.get("fortnightEnd"));
final List<String> maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS"));
maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED));
// Only up to the reporting date: days after it have not happened, and averaging an
// unfinished fortnight over its whole length would understate what is held so far.
final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd;
final Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles);
final BigDecimal average = (BigDecimal) daily.get("average");
final BigDecimal lowest = (BigDecimal) daily.get("lowest");
final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2,
RoundingMode.HALF_UP);
daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd));
daily.put("requiredAverage", required);
daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT);
daily.put("dailyMinimumAmount", dailyFloor);
daily.put("averageMet", average.compareTo(required) >= 0);
daily.put("dailyMinimumMet", lowest.compareTo(dailyFloor) >= 0);
daily.put("averageShortfall", average.subtract(required));
response.put("maintenance", daily);
}
// The Memorandum and the two Annexures Form A carries alongside the main statement. They are
// sections of the return rather than extra lines on it, so they are returned separately and
// print under their own headings rather than being mistaken for figures entering the CRR
// arithmetic - which none of them do.
response.put("memorandum", sectionOf(heads, "Memorandum - "));
response.put("annexureA", sectionOf(heads, "Annexure A - "));
response.put("annexureB", sectionOf(heads, "Annexure B - "));
response.put("unmetRequirements", unmet(rate, "CRR", dtl, cash, banks));
return gson.toJson(response);
}
/**
* The heads belonging to one titled section of the return, with the prefix stripped from each
* label so "Memorandum - Reserves" prints as "Reserves" under the Memorandum heading.
*
* Selected by label prefix rather than by a column, because a section is a presentational
* grouping: a head moves between sections by being renamed, and an administrator adding a head
* to the Memorandum does so by naming it that way rather than by learning a second concept.
*/
/** Prefixes that move a head out of the main statement and into its own titled section. */
private static final List<String> SECTION_PREFIXES = List.of("Memorandum - ", "Annexure A - ", "Annexure B - ");
private static boolean inNamedSection(final CustomHead head) {
if (head.label == null) {
return false;
}
return SECTION_PREFIXES.stream().anyMatch(head.label::startsWith);
}
private static List<Map<String, Object>> sectionOf(final List<CustomHead> heads, final String prefix) {
final List<Map<String, Object>> rows = new ArrayList<>();
for (final CustomHead head : heads) {
if (head.label == null || !head.label.startsWith(prefix)) {
continue;
}
final Map<String, Object> row = new LinkedHashMap<>();
row.put("label", head.label.substring(prefix.length()));
row.put("configKey", head.figure.configKey);
row.put("configured", head.figure.configured);
row.put("amount", head.figure.configured ? head.figure.amount : null);
row.put("accounts", head.figure.accounts);
rows.add(row);
}
return rows;
}
/**
* Form 9 - SLR, filed at the end of the month.
*
* Unlike Form 1, SLR is reported on the month-end position: base and holdings are both taken as
* at the month end, with no lag, so the return states the position actually held at close.
*/
@GET
@Path("form9")
public String form9(@QueryParam("reportingDate") final String reportingDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate requested = parseOrBusinessDate(reportingDateParam);
final LocalDate monthEnd = requested.with(TemporalAdjusters.lastDayOfMonth());
final Map<String, Object> response = new LinkedHashMap<>();
response.put("formCode", "FORM_9");
response.put("formName", "Form 9 - Statutory Liquidity Ratio (SLR)");
response.put("frequency", "Monthly");
response.put("requestedDate", requested.toString());
response.put("reportingDate", monthEnd.toString());
response.put("monthStart", requested.withDayOfMonth(1).toString());
final Map<String, Object> basis = new LinkedHashMap<>();
basis.put("dtlAsOnDate", monthEnd.toString());
basis.put("holdingsAsOnDate", monthEnd.toString());
basis.put("note", "SLR is reported on the month-end position: both the DTL base and the "
+ "SLR-eligible investments are taken as at " + monthEnd + ".");
response.put("basis", basis);
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId);
final RoleFigure investments = figureFor("SLR_INVESTMENTS", monthEnd, officeId);
final RoleFigure cash = figureFor("CASH_IN_HAND", monthEnd, officeId);
final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", monthEnd, officeId);
final Rate rate = rateInForce("SLR", monthEnd);
response.put("rateType", "SLR");
response.put("ratePercentage", rate == null ? null : rate.percentage);
response.put("rateIsSeedDefault", rate != null && rate.seedDefault);
// The same administrator-defined heads Form 1 reads. Both dates are month-end here, so the
// base and the holdings are struck at one close of business.
final List<CustomHead> heads = customHeads("RESERVE", monthEnd, monthEnd, officeId);
final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY));
final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true));
final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false));
final BigDecimal netted = banks.amount.add(totalOf(heads, USAGE_ASSET_NET));
// SLR is charged on the same NET base as CRR. Both sides are month-end here, so the
// inter-bank figure is the one already read above rather than a second read at another date.
final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO);
final List<Map<String, Object>> lines = new ArrayList<>();
lines.add(line("Demand and Time Liabilities (DTL)", dtl, monthEnd));
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage)) {
lines.add(line(head.label, head.figure, monthEnd));
}
}
lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, monthEnd));
lines.add(subLine("of which: Time Liabilities", time, dtl.configured, monthEnd));
lines.add(line("Less: balances with other banks — deducted from the DTL base", banks, monthEnd));
for (final CustomHead head : heads) {
if (USAGE_ASSET_NET.equals(head.usage)) {
lines.add(line("Less: " + head.label, head.figure, monthEnd));
}
}
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd));
lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd));
// Shown for completeness of the month-end liquid position; not added into SLR maintained.
// A custom MAINTAINED head IS added: it was declared as counting toward the requirement,
// and Form 9's requirement is the SLR one.
lines.add(line("Cash in hand (memo)", cash, monthEnd));
for (final CustomHead head : heads) {
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) {
// Every such head is a memo on THIS return, including one marked MAINTAINED: only
// SLR-eligible investments count toward SLR, so the label must not suggest the
// figure was added into the maintained total when it was not.
lines.add(line(head.label + " (memo)", head.figure, monthEnd));
}
}
response.put("lines", lines);
final boolean computable = rate != null && dtl.configured && investments.configured;
response.put("computable", computable);
if (computable) {
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
// SLR admits government securities, not cash. A head marked MAINTAINED is therefore NOT
// added here the way it is on Form 1: it was declared as counting toward a requirement,
// and on Form 9 only SLR-eligible investments do. Adding it would let a cash-like head
// configured for CRR inflate the SLR position, which is the error this return exists to
// detect. A bank with more eligible securities maps them to SLR_INVESTMENTS.
final BigDecimal held = investments.amount;
response.put("dtlAmount", liabilities);
response.put("demandLiabilities", dtl.configured ? demand : null);
response.put("timeLiabilities", dtl.configured ? time : null);
response.put("interBankAssets", netted);
response.put("ndtlAmount", ndtl);
response.put("requiredAmount", required);
response.put("maintainedAmount", held);
response.put("surplusOrShortfall", held.subtract(required));
response.put("compliant", held.compareTo(required) >= 0);
response.put("maintainedPercentage", percentageOf(held, ndtl));
}
response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments));
return gson.toJson(response);
}
// ─────────────────────────── computation ───────────────────────────
/** A role's balance, plus whether it was configured at all - zero and unset are different. */
private static final class RoleFigure {
final String configKey;
boolean configured;
BigDecimal amount = BigDecimal.ZERO;
/**
* The same total split by liability class, for the roles where RBI reports the two heads
* separately. Both stay zero on asset roles, where the distinction has no meaning.
*/
BigDecimal demandAmount = BigDecimal.ZERO;
BigDecimal timeAmount = BigDecimal.ZERO;
final List<Map<String, Object>> accounts = new ArrayList<>();
/** Accounts pinned to the role but left out of the total, with why. */
final List<String> excluded = new ArrayList<>();
/**
* Liability rows carrying no DEMAND/TIME class. They are counted in the role total, so the
* return still adds up, but they cannot be placed under either head - reported so the gap is
* visible rather than showing as a total its own two halves fall short of.
*/
final List<String> unclassified = new ArrayList<>();
RoleFigure(final String configKey) {
this.configKey = configKey;
}
}
/**
* Sums the balances of every GL account pinned to a role, as at a date.
*
* Each account's balance is taken on its own natural side, so a liability role such as DTL
* reports as a positive number rather than as a negative asset-side figure.
*
* A mapping row contributes its configured PORTION of the balance, not the whole of it. RBI
* treats part of a savings balance as a demand liability and the rest as time, so the same
* account is mapped twice under one role - once per class, each with its own weight - and the
* two slices are accumulated under their respective heads.
*/
private RoleFigure figureFor(final String configKey, final LocalDate asOnDate, final Long officeId) {
final RoleFigure figure = new RoleFigure(configKey);
// Every account pinned to the role is read back, usable or not. Excluding the unusable ones
// in SQL would shrink the maintained figure with nothing on the return to say an account had
// dropped out, so they are carried through here and reported by unmet() instead.
//
// Keyed on the mapping row rather than the account: one account can appear more than once
// under a role, and grouping by account would collapse a demand slice and a time slice into
// a single row whose weight is neither.
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT c.id AS mapping_id, c.weight_percentage, c.liability_class,
a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage
FROM m_rbi_reserve_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
WHERE c.config_key = ?
ORDER BY a.gl_code, c.liability_class
""", configKey);
if (accounts.isEmpty()) {
return figure;
}
figure.configured = true;
for (final Map<String, Object> account : accounts) {
final Long accountId = ((Number) account.get("id")).longValue();
final int classification = ((Number) account.get("classification_enum")).intValue();
final String glCode = (String) account.get("gl_code");
final String liabilityClass = (String) account.get("liability_class");
// NOT NULL in the schema, but read defensively: a row that somehow arrived without one
// must not multiply a balance by zero and silently drop it out of the return.
final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED
: (BigDecimal) account.get("weight_percentage");
// A pinned account can stop being usable after it was mapped: disabled, or turned into a
// header whose balance is the sum of its children and would double-count them. Either
// way its balance must not silently enter the total - the account is named on the return
// as excluded, so the figure can be read for what it is.
final boolean disabled = Boolean.TRUE.equals(account.get("disabled"));
final boolean header = account.get("account_usage") != null
&& ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT;
if (disabled || header) {
figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)"));
final Map<String, Object> skipped = new LinkedHashMap<>();
skipped.put("glAccountId", accountId);
skipped.put("glCode", glCode);
skipped.put("name", account.get("name"));
skipped.put("balance", null);
skipped.put("weightPercentage", weight);
skipped.put("liabilityClass", liabilityClass);
skipped.put("excluded", disabled ? "disabled" : "header");
figure.accounts.add(skipped);
continue;
}
final StringBuilder sql = new StringBuilder("""
SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit,
COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit
FROM acc_gl_journal_entry
WHERE account_id = ? AND entry_date <= ?
AND (reversed IS NULL OR reversed = false)
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""");
final List<Object> args = new ArrayList<>(List.of(accountId, java.sql.Date.valueOf(asOnDate)));
if (officeId != null) {
sql.append(" AND office_id = ?");
args.add(officeId);
}
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
final BigDecimal debit = (BigDecimal) row.get("total_debit");
final BigDecimal credit = (BigDecimal) row.get("total_credit");
final boolean debitNatured = classification == ASSET || classification == EXPENSE;
final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit);
// The role takes its configured portion of the balance. Rounded to paise at the slice,
// so the demand and time halves shown on the return are the very numbers that were
// added together - rounding only the total would leave the two heads failing to sum to
// it by a paisa, which on a filed return reads as an error.
final BigDecimal contribution = weight.compareTo(HUNDRED) == 0 ? balance
: balance.multiply(weight).divide(HUNDRED, 2, RoundingMode.HALF_UP);
figure.amount = figure.amount.add(contribution);
if (DEMAND.equals(liabilityClass)) {
figure.demandAmount = figure.demandAmount.add(contribution);
} else if (TIME.equals(liabilityClass)) {
figure.timeAmount = figure.timeAmount.add(contribution);
} else if (LIABILITY_ROLES.contains(configKey)) {
figure.unclassified.add(glCode + " — " + account.get("name"));
}
final Map<String, Object> detail = new LinkedHashMap<>();
detail.put("glAccountId", accountId);
detail.put("glCode", glCode);
detail.put("name", account.get("name"));
// Both the account's own balance and the slice taken from it. A reviewer checking a
// weighted line against the ledger needs the figure it was derived from, not only the
// result - otherwise a wrong weight and a wrong balance look identical on the return.
detail.put("accountBalance", balance);
detail.put("weightPercentage", weight);
detail.put("liabilityClass", liabilityClass);
detail.put("balance", contribution);
figure.accounts.add(detail);
}
return figure;
}
/** An administrator-defined head, with the figure it evaluated to. */
private static final class CustomHead {
final String label;
final String usage;
final RoleFigure figure;
CustomHead(final String label, final String usage, final RoleFigure figure) {
this.label = label;
this.usage = usage;
this.figure = figure;
}
}
/**
* The heads an administrator added to a return, each evaluated as at the date its treatment
* calls for.
*
* Liabilities are read on the base date and assets on the holdings date, for the same reason the
* built-in roles are: the base is struck at an earlier close of business than the position held
* against it, and reading both at one date would compare figures from different days.
*/
/**
* The keys of administrator-defined heads with one usage - so the daily average counts whatever
* the bank declared as counting toward the requirement, not only the two built-in roles.
*/
private List<String> customKeysWithUsage(final String group, final String usage) {
return jdbcTemplate.queryForList("""
SELECT config_key FROM m_rbi_reserve_role
WHERE role_group = ? AND role_usage = ?
ORDER BY position, id
""", String.class, group, usage);
}
private List<CustomHead> customHeads(final String group, final LocalDate baseDate, final LocalDate holdingsDate,
final Long officeId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT config_key, label, role_usage
FROM m_rbi_reserve_role
WHERE role_group = ?
ORDER BY position, label
""", group);
final List<CustomHead> heads = new ArrayList<>();
for (final Map<String, Object> row : rows) {
final String key = (String) row.get("config_key");
final String usage = (String) row.get("role_usage");
final LocalDate asOn = USAGE_LIABILITY.equals(usage) || USAGE_ASSET_NET.equals(usage) ? baseDate
: holdingsDate;
final RoleFigure figure = figureFor(key, asOn, officeId);
// A head with nothing mapped is left off the return entirely rather than printed as a
// nil line. It was added but never finished, and an empty row invites the reader to
// treat a gap in configuration as a balance of zero.
if (figure.configured) {
heads.add(new CustomHead((String) row.get("label"), usage, figure));
}
}
return heads;
}
/** The total a set of custom heads contributes under one treatment. */
private static BigDecimal totalOf(final List<CustomHead> heads, final String usage) {
BigDecimal total = BigDecimal.ZERO;
for (final CustomHead head : heads) {
if (head.usage.equals(usage)) {
total = total.add(head.figure.amount);
}
}
return total;
}
/** The demand or time slice those heads contribute, for the split shown on the face of the return. */
private static BigDecimal liabilitySliceOf(final List<CustomHead> heads, final boolean demand) {
BigDecimal total = BigDecimal.ZERO;
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage)) {
total = total.add(demand ? head.figure.demandAmount : head.figure.timeAmount);
}
}
return total;
}
/** A rate and whether it is still the placeholder the migration seeded. */
private static final class Rate {
final BigDecimal percentage;
final boolean seedDefault;
Rate(final BigDecimal percentage, final boolean seedDefault) {
this.percentage = percentage;
this.seedDefault = seedDefault;
}
}
/**
* The rate in force on a date - the most recent one effective on or before it. Null when the
* date precedes every rate on record, which stops the return computing rather than defaulting to
* a rate nobody set.
*/
private Rate rateInForce(final String rateType, final LocalDate onDate) {
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
SELECT percentage, is_seed_default FROM m_rbi_reserve_rate
WHERE rate_type = ? AND effective_from <= ?
ORDER BY effective_from DESC LIMIT 1
""", rateType, java.sql.Date.valueOf(onDate));
if (found.isEmpty()) {
return null;
}
final Map<String, Object> row = found.get(0);
return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default")));
}
/**
* The reporting Friday on or before a date. RBI reckons fortnightly balances at the close of
* business on alternate Fridays; a raw calendar offset can land on a Sunday, which has no close
* of business to read a balance at.
*/
private static LocalDate reportingFridayOnOrBefore(final LocalDate date) {
return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY));
}
/**
* The date itself when the bank was open, otherwise the working day before it.
*
* A reporting Friday that is a declared holiday has no close of business, so there is no balance
* to strike: the statutory framework takes the preceding working day instead. Sundays are
* treated as non-working alongside declared holidays; Saturdays are not, because a reporting
* Friday never falls on one and a holiday shifted back from Friday legitimately lands on
* Thursday.
*
* Bounded at ten days. A chain of holidays longer than that is a data problem rather than a
* calendar, and walking back indefinitely would silently report a balance from weeks earlier.
*/
private LocalDate precedingWorkingDay(final LocalDate date) {
LocalDate candidate = date;
for (int i = 0; i < 10; i++) {
if (!isNonWorkingDay(candidate)) {
return candidate;
}
candidate = candidate.minusDays(1);
}
return date;
}
private boolean isNonWorkingDay(final LocalDate date) {
if (date.getDayOfWeek() == DayOfWeek.SUNDAY) {
return true;
}
// status_enum 300 is an activated holiday; a draft or deleted one does not close the bank.
final Integer holidays = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_holiday
WHERE status_enum = 300 AND ? BETWEEN from_date AND to_date
""", Integer.class, java.sql.Date.valueOf(date));
return holidays != null && holidays > 0;
}
/**
* The average of what was maintained across every day of the fortnight, which is the figure CRR
* compliance actually turns on.
*
* RBI does not read a single day's balance: a bank must keep at least a daily minimum and average
* the full requirement over the fortnight. A return that reported only the reporting date's
* position would call a bank compliant that held nothing for thirteen days and topped up on the
* last one.
*
* Every day is read, including holidays and Sundays: the balance stands over a closed day, and
* excluding those days would let a fortnight ending on a long weekend average away a shortfall.
*/
private Map<String, Object> dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId,
final List<String> maintainedRoles) {
final List<Map<String, Object>> days = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
BigDecimal lowest = null;
LocalDate lowestOn = null;
for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) {
BigDecimal held = BigDecimal.ZERO;
for (final String role : maintainedRoles) {
held = held.add(figureFor(role, day, officeId).amount);
}
total = total.add(held);
if (lowest == null || held.compareTo(lowest) < 0) {
lowest = held;
lowestOn = day;
}
final Map<String, Object> row = new LinkedHashMap<>();
row.put("date", day.toString());
row.put("amount", held);
days.add(row);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("days", days);
out.put("dayCount", days.size());
out.put("average", days.isEmpty() ? BigDecimal.ZERO
: total.divide(BigDecimal.valueOf(days.size()), 2, RoundingMode.HALF_UP));
out.put("lowest", lowest == null ? BigDecimal.ZERO : lowest);
out.put("lowestOn", lowestOn == null ? null : lowestOn.toString());
return out;
}
/** Which half of the month a reporting date falls in - Form 1 is filed once per fortnight. */
private static Map<String, Object> fortnightOf(final LocalDate date) {
final boolean first = date.getDayOfMonth() <= 15;
final LocalDate start = first ? date.withDayOfMonth(1) : date.withDayOfMonth(16);
final LocalDate end = first ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth());
final Map<String, Object> out = new LinkedHashMap<>();
out.put("fortnight", first ? 1 : 2);
out.put("fortnightLabel", (first ? "First" : "Second") + " fortnight");
out.put("fortnightStart", start.toString());
out.put("fortnightEnd", end.toString());
return out;
}
/**
* A figure as a percentage of a base, or null where that percentage would not mean anything.
*
* Null on a base of zero OR LESS, not only on zero: the base is now NDTL, which is a subtraction
* and can come out negative where inter-bank assets exceed the liabilities - an unusual position,
* but one a small bank can genuinely hold. Dividing by it yields a negative "maintained %" that
* reads as a shortfall on a return where holdings are ample, which is worse than showing nothing.
*/
private static BigDecimal percentageOf(final BigDecimal amount, final BigDecimal base) {
if (base == null || base.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
return amount.multiply(HUNDRED).divide(base, 4, RoundingMode.HALF_UP);
}
/**
* A derived row - a split of a role's total, or a figure computed from several roles. It has no
* accounts of its own because it is not read from the ledger: its parts are already listed under
* the lines it was derived from, and repeating them here would show the same balance twice.
*/
private static Map<String, Object> subLine(final String label, final BigDecimal amount, final boolean configured,
final LocalDate asOnDate) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("configKey", null);
out.put("configured", configured);
out.put("asOnDate", asOnDate.toString());
out.put("amount", configured ? amount : null);
out.put("derived", true);
out.put("accounts", List.of());
return out;
}
private static Map<String, Object> line(final String label, final RoleFigure figure, final LocalDate asOnDate) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("label", label);
out.put("configKey", figure.configKey);
out.put("configured", figure.configured);
out.put("asOnDate", asOnDate.toString());
// Null rather than zero when unconfigured: the screen must be able to tell "nothing mapped"
// from "mapped and genuinely nil".
out.put("amount", figure.configured ? figure.amount : null);
out.put("accounts", figure.accounts);
return out;
}
/**
* Human-readable reasons the return could not be computed, for the screen to show verbatim.
*
* A rate still carrying the seeded placeholder is listed here too. It does not stop the return
* computing - the figures are arithmetically right for that rate - but the rate itself is one
* nobody at the bank confirmed, so the return says so rather than presenting a required amount
* derived from a placeholder as though it were settled.
*/
private static List<String> unmet(final Rate rate, final String rateType, final RoleFigure... figures) {
final List<String> reasons = new ArrayList<>();
if (rate == null) {
reasons.add("No " + rateType + " rate is on record effective on or before the reporting date. "
+ "Set one under Organization > RBI Reserve Configuration.");
} else if (rate.seedDefault) {
reasons.add("The " + rateType + " rate of " + rate.percentage
+ "% is the placeholder shipped with this installation, not a rate this bank has confirmed. "
+ "Confirm the rate in force under Organization > RBI Reserve Configuration before filing.");
}
for (final RoleFigure figure : figures) {
if (!figure.configured) {
reasons.add("No GL accounts are mapped to " + figure.configKey
+ ". Map them under Organization > RBI Reserve Configuration.");
} else if (!figure.unclassified.isEmpty()) {
// Counted in the base but placed under neither head, so the demand and time lines do
// not sum to the total printed above them. On a filed return that reads as an
// arithmetic error, so the accounts responsible are named.
reasons.add(String.join(", ", figure.unclassified)
+ " — mapped to the liability base but not marked Demand or Time, so the figure is counted in "
+ "NDTL while appearing under neither head. Classify them under Organization > "
+ "RBI Reserve Configuration.");
}
if (!figure.excluded.isEmpty()) {
// The figure is short by these accounts' balances. Saying so is the difference
// between a reviewable return and one that under-reports without a trace.
reasons.add(figure.configKey + " leaves out " + String.join(", ", figure.excluded)
+ " — mapped but no longer postable, so the balance shown excludes them. "
+ "Review the mapping under Organization > RBI Reserve Configuration.");
}
}
return reasons;
}
private static LocalDate parseOrBusinessDate(final String param) {
return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param);
}
}
@@ -227,14 +227,14 @@ public class CurrentProductsApiResource {
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
if (savingProductData.hasAccountingEnabled()) {
// Read under the product's own CURRENT type. Read as a savings product it returned the GLs of whichever
// savings product shares this id. Payment-channel and per-charge mappings are savings features a current
// product does not write, so none are returned.
final Map<String, Object> accountingMappings = accountMappingReadPlatformService
.fetchAccountMappingDetailsForSavingsProduct(productId, savingProductData.accountingRuleTypeId());
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = accountMappingReadPlatformService
.fetchPaymentTypeToFundSourceMappingsForSavingsProduct(productId);
Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = accountMappingReadPlatformService
.fetchFeeToIncomeAccountMappingsForSavingsProduct(productId);
Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = accountMappingReadPlatformService
.fetchPenaltyToIncomeAccountMappingsForSavingsProduct(productId);
.fetchAccountMappingDetailsForCurrentProduct(productId, savingProductData.accountingRuleTypeId());
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = null;
final Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = null;
final Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = null;
savingProductData = SavingsProductData.withAccountingDetails(savingProductData, accountingMappings,
paymentChannelToFundSourceMappings, feeToGLAccountMappings, penaltyToGLAccountMappings);
}
@@ -36,6 +36,8 @@ import lombok.Getter;
@Builder
public class DormantAccountData {
/** SAVINGS or CURRENT: which kind of account accountId refers to. */
private final String accountType;
private final Long accountId;
private final String accountNo;
private final Long clientId;
@@ -23,6 +23,7 @@ import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.service.CurrentAccountDormancyJobService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.springframework.batch.core.StepContribution;
@@ -38,6 +39,7 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet {
private final SavingsAccountReadPlatformService savingAccountReadPlatformService;
private final SavingsAccountWritePlatformService savingsAccountWritePlatformService;
private final CurrentAccountDormancyJobService currentAccountDormancyJobService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
@@ -63,6 +65,9 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet {
savingsAccountWritePlatformService.escheat(savingsId);
}
}
// Current accounts run the same ladder on their own current product's settings.
currentAccountDormancyJobService.run(tenantLocalDate);
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount;
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.springframework.stereotype.Service;
/**
* The cash GL a current account's deposit or withdrawal posts against.
*
* <p>
* A current product maps only the customer's liability (Current Control); the cash side is the branch's cash, which
* is the same GL the cashier's Transaction Entry screen uses: the Financial Activity mapping, Cash at Main Vault first
* and Cash at Teller after it -- the order that screen picks in, so the cashier and the ledger always name the same GL.
*/
@Service
@RequiredArgsConstructor
public class CurrentAccountCashGlResolver {
private final FinancialActivityAccountRepository financialActivityAccountRepository;
/** The branch cash GL, or null when neither cash activity is mapped. */
public GLAccount resolve() {
for (final FinancialActivity activity : new FinancialActivity[] { FinancialActivity.CASH_AT_MAINVAULT,
FinancialActivity.CASH_AT_TELLER }) {
final FinancialActivityAccount mapping = financialActivityAccountRepository.findByFinancialActivityType(activity.getValue());
if (mapping != null && mapping.getGlAccount() != null) {
return mapping.getGlAccount();
}
}
return null;
}
}
@@ -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.savings.service;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* The dormancy ladder for current accounts, run by the same nightly job as savings.
*
* <p>
* Current accounts live in m_current_account, which the savings job never reads, so until now a current account was
* never marked inactive or dormant and never escheated. This walks the same three rungs -- Inactive, Dormant, Escheat
* -- against each account's own current product (m_current_product: is_dormancy_tracking_active and its three day
* counts), measuring idle days with DormancyClock exactly as savings does, so the two kinds of account can never
* disagree about the same customer.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CurrentAccountDormancyJobService {
private static final int SUB_NONE = 0;
private static final int SUB_INACTIVE = 100;
private static final int SUB_DORMANT = 200;
private final JdbcTemplate jdbcTemplate;
private final DatabaseSpecificSQLGenerator sqlGenerator;
private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService;
public void run(final LocalDate businessDate) {
// A re-KYC restarts the clock (DormancyClock), so an account stamped Inactive/Dormant whose idle days are now
// back under the inactive threshold has been reactivated: clear the stamp before climbing the ladder again.
final int reactivated = jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = 0 WHERE id IN ("
+ "SELECT ca.id FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id"
+ " WHERE ca.status_enum = 300 AND ca.sub_status_enum IN (100, 200) AND cp.days_to_inactive > 0"
+ " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " < cp.days_to_inactive)", businessDate);
if (reactivated > 0) {
log.info("[Dormancy] {} current accounts reactivated after re-KYC", reactivated);
}
final int inactive = stamp(businessDate, SUB_NONE, "days_to_inactive", SUB_INACTIVE);
final int dormant = stamp(businessDate, SUB_INACTIVE, "days_to_dormancy", SUB_DORMANT);
int escheated = 0;
for (final Long accountId : pending(businessDate, SUB_DORMANT, "days_to_escheat")) {
try {
if (currentAccountTransactionWritePlatformService.escheat(accountId)) {
escheated++;
}
} catch (RuntimeException e) {
log.error("[Dormancy] Escheat failed for current account {}: {}", accountId, e.getMessage(), e);
}
}
log.info("[Dormancy] Current accounts: {} marked inactive, {} marked dormant, {} escheated", inactive, dormant, escheated);
}
/** Moves every account on rung {@code from} whose idle days reached {@code daysColumn} up to {@code to}. */
private int stamp(final LocalDate businessDate, final int from, final String daysColumn, final int to) {
final List<Long> ids = pending(businessDate, from, daysColumn);
for (final Long accountId : ids) {
jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = ? WHERE id = ? AND status_enum = 300 AND sub_status_enum = ?",
to, accountId, from);
}
return ids.size();
}
private List<Long> pending(final LocalDate businessDate, final int subStatus, final String daysColumn) {
final String sql = "SELECT ca.id FROM m_current_account ca"
+ " JOIN m_current_product cp ON cp.id = ca.product_id AND cp.is_dormancy_tracking_active = true"
+ " WHERE ca.status_enum = 300 AND ca.sub_status_enum = ? AND cp." + daysColumn + " > 0"
+ " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " >= cp." + daysColumn;
return jdbcTemplate.queryForList(sql, Long.class, subStatus, businessDate);
}
}
@@ -23,12 +23,14 @@ import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -71,9 +73,61 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
response.put("charges", retrieveCharges(accountId));
response.put("transactions", retrieveTransactions(accountId));
response.put("accountFacilityConfig", retrieveFacilityConfig(accountId));
addDormancy(accountId, response);
return response;
}
/**
* The dormancy countdown the account screen's Dormancy Risk Monitor reads, measured exactly as a savings account's
* is: from DormancyClock's last-live date (activation or the latest re-KYC), against the product's thresholds and
* the business date.
*
* The product's dormancyAlert decides whether the screen shows the monitor at all. subStatus is replaced by the
* stage the elapsed days have earned, as the savings read does, because no job stamps current accounts yet; a
* Block stamp (300 and above) is not a dormancy stage and is left alone.
*/
private void addDormancy(final Long accountId, final Map<String, Object> response) {
final String sql = "SELECT cp.dormancy_alert, cp.is_dormancy_tracking_active, cp.days_to_inactive, cp.days_to_dormancy,"
+ " cp.days_to_escheat, ca.status_enum, ca.sub_status_enum, " + DormancyClock.lastLiveOn("ca") + " AS last_live_on"
+ " FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id WHERE ca.id = ?";
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, accountId);
if (rows.isEmpty()) {
return;
}
final Map<String, Object> row = rows.get(0);
final boolean tracking = toBoolean(row.get("is_dormancy_tracking_active"));
response.put("dormancyAlert", toBoolean(row.get("dormancy_alert")));
response.put("isDormancyTrackingActive", tracking);
final LocalDate lastLive = row.get("last_live_on") instanceof java.sql.Date d ? d.toLocalDate() : null;
response.put("lastActiveTransactionDate", lastLive == null ? null : lastLive.toString());
if (!tracking || lastLive == null || toInt(row.get("status_enum"), 100) != 300) {
return;
}
final Integer toInactive = row.get("days_to_inactive") == null ? null : toInt(row.get("days_to_inactive"), 0);
final Integer toDormant = row.get("days_to_dormancy") == null ? null : toInt(row.get("days_to_dormancy"), 0);
final Integer toEscheat = row.get("days_to_escheat") == null ? null : toInt(row.get("days_to_escheat"), 0);
final LocalDate today = DateUtils.getBusinessLocalDate();
final long idle = ChronoUnit.DAYS.between(lastLive, today);
int subStatus = toInt(row.get("sub_status_enum"), 0);
if (subStatus < 300) {
subStatus = toDormant != null && toDormant > 0 && idle >= toDormant ? 200
: toInactive != null && toInactive > 0 && idle >= toInactive ? 100 : 0;
response.put("subStatus", buildSubStatus(subStatus));
}
if (subStatus < 100 && toInactive != null) {
response.put("daysToInactive", ChronoUnit.DAYS.between(today, lastLive.plusDays(toInactive)));
}
if (subStatus < 200 && toDormant != null) {
response.put("daysToDormancy", ChronoUnit.DAYS.between(today, lastLive.plusDays(toDormant)));
}
if (toEscheat != null) {
response.put("daysToEscheat", ChronoUnit.DAYS.between(today, lastLive.plusDays(toEscheat)));
}
}
private Map<String, Object> retrieveFacilityConfig(Long accountId) {
final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?";
List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
@@ -207,7 +261,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
tt.put("rejectTransfer", false);
tt.put("writtenoff", false);
tt.put("overdraftFee", false);
tt.put("escheat", false);
tt.put("escheat", typeEnum == 19);
tt.put("amountHold", typeEnum == 8);
tt.put("amountRelease", typeEnum == 9);
tt.put("accrual", false);
@@ -219,6 +273,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
case 7 -> { tt.put("code", "savingsTransactionType.payCharge"); tt.put("value", "Charge Deduction"); }
case 8 -> { tt.put("code", "savingsTransactionType.onHold"); tt.put("value", "Amount on hold"); }
case 9 -> { tt.put("code", "savingsTransactionType.release"); tt.put("value", "Release Amount"); }
case 19 -> { tt.put("code", "savingsTransactionType.escheat"); tt.put("value", "Escheat"); }
default -> { tt.put("code", "savingsTransactionType.deposit"); tt.put("value", "Deposit"); }
}
return tt;
@@ -477,6 +532,8 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
out.put("allowOverdraft", row.get("allow_overdraft"));
out.put("overdraftLimit", row.get("overdraft_limit"));
out.put("minOverdraftForInterestCalculation", row.get("min_overdraft_for_interest_calculation"));
out.put("nominalAnnualInterestRateOverdraft", row.get("nominal_annual_interest_rate_overdraft"));
out.put("validForPeriod", row.get("valid_for_period"));
out.put("minRequiredBalance", row.get("min_required_balance"));
out.put("enforceMinRequiredBalance", row.get("enforce_min_required_balance"));
out.put("onHoldFunds", row.get("on_hold_funds_derived"));
@@ -576,12 +633,14 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
Map<String, Object> s = new LinkedHashMap<>();
s.put("id", subStatusEnum);
String value = switch (subStatusEnum) {
// Same codes as savings (SavingsAccountSubStatusEnum), so both kinds of account read alike.
case 0 -> "None";
case 100 -> "Inactive";
case 200 -> "Dormant";
case 300 -> "Block";
case 400 -> "Block Debit";
case 300 -> "Escheat";
case 400 -> "Block";
case 500 -> "Block Credit";
case 600 -> "Block Debit";
default -> "None";
};
s.put("code", "SavingsAccountSubStatusEnum." + value.replace(" ", ""));
@@ -589,9 +648,10 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
s.put("none", subStatusEnum == 0);
s.put("inactive", subStatusEnum == 100);
s.put("dormant", subStatusEnum == 200);
s.put("block", subStatusEnum == 300);
s.put("blockDebit", subStatusEnum == 400);
s.put("escheat", subStatusEnum == 300);
s.put("block", subStatusEnum == 400);
s.put("blockCredit", subStatusEnum == 500);
s.put("blockDebit", subStatusEnum == 600);
return s;
}
@@ -689,13 +749,15 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
};
}
// SavingsPostingInterestPeriodType codes -- the ones the product form saves and the account copies.
private String interestPostingPeriodValue(int id) {
return switch (id) {
case 1 -> "Monthly";
case 2 -> "Quarterly";
case 3 -> "Biannual";
case 4 -> "Annually";
default -> "Annually";
case 1 -> "Daily";
case 4 -> "Monthly";
case 5 -> "Quarterly";
case 6 -> "Biannual";
case 7 -> "Annually";
default -> "Monthly";
};
}
@@ -34,6 +34,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
@@ -45,7 +46,9 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Slf4j
@@ -63,6 +66,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
private final JournalEntryRepository journalEntryRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final KycRestrictionGuard kycRestrictionGuard;
private final AppUserRepositoryWrapper appUserRepositoryWrapper;
private final CurrentAccountCashGlResolver cashGlResolver;
@Transactional
@Override
@@ -77,6 +83,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount");
validatePositiveAmount(amount, accountId, "deposit");
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.CREDIT);
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateBreakdown(command, amount, "DEPOSIT");
@@ -123,6 +130,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount");
validatePositiveAmount(amount, accountId, "withdrawal");
// A dormant account (its current product's dormancy days passed with no re-KYC) takes no debit
// until the customer is re-verified; the same rule the savings counter applies.
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
final BigDecimal currentBalance = safeBalance(account.getAccountBalanceDerived());
final BigDecimal onHold = safeBalance(account.getOnHoldFundsDerived());
@@ -159,7 +169,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
account.getClient() != null ? account.getClient().getCifNumber() : null, txnDate,
"WITHDRAWAL");
// Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR SAVINGS_REFERENCE (cash out)
// Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR branch cash GL (cash out)
postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal");
return new CommandProcessingResultBuilder()
@@ -391,6 +401,59 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
.build();
}
/**
* Escheat, as the savings dormancy ladder does it: the balance goes to the product's escheat liability (Dr Current
* Control, Cr Escheat Liability) and the account is closed with sub-status Escheat (300) by the system user.
*
* Its own transaction, so one account that cannot be escheated never rolls back the rest of the night's batch.
* Refused -- logged, account untouched -- where the move would be wrong or unrecorded: a debit balance is money the
* customer owes, not unclaimed money; a hold is a lien someone else has on the funds; and with cash accounting a
* balance must not leave the account unless the escheat GL exists to receive it.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public boolean escheat(final Long accountId) {
final CurrentAccount account = currentAccountRepository.findById(accountId).orElse(null);
if (account == null || !account.isActive()) {
return false;
}
final BigDecimal balance = safeBalance(account.getAccountBalanceDerived());
if (balance.signum() < 0 || safeBalance(account.getOnHoldFundsDerived()).signum() > 0) {
log.warn("[Escheat] Current account {} skipped: debit balance {} or funds on hold {}", accountId, balance,
account.getOnHoldFundsDerived());
return false;
}
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
final boolean accounted = product != null && product.getAccountingType() != null && product.getAccountingType() != 1;
final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue());
final GLAccount escheatAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
if (accounted && balance.signum() > 0 && (controlAccount == null || escheatAccount == null)) {
log.warn("[Escheat] Current account {} skipped: product {} has no Current Control or Escheat Liability GL mapped",
accountId, account.getProductId());
return false;
}
final LocalDate today = DateUtils.getBusinessLocalDate();
if (balance.signum() > 0) {
final CurrentAccountTransaction txn = CurrentAccountTransaction.escheat(account, account.getOffice(), balance, today);
txn.setRunningBalanceDerived(BigDecimal.ZERO);
currentAccountTransactionRepository.saveAndFlush(txn);
account.setAccountBalanceDerived(BigDecimal.ZERO);
account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(balance));
if (accounted) {
final String ref = "CAT" + txn.getId();
final String description = "Current Account Escheat";
postGlEntry(account, controlAccount, JournalEntryType.DEBIT, balance, today, ref, description);
postGlEntry(account, escheatAccount, JournalEntryType.CREDIT, balance, today, ref, description);
}
}
account.setSubStatusEnum(300);
account.close(today, appUserRepositoryWrapper.fetchSystemUser());
currentAccountRepository.saveAndFlush(account);
log.info("[Escheat] Current account {} escheated, balance {} moved to escheat liability", accountId, balance);
return true;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
private CurrentAccount findActiveAccount(final Long accountId) {
@@ -443,37 +506,51 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
}
}
// GL: cashEntryType=DEBIT/CREDIT for SAVINGS_REFERENCE; controlEntryType for SAVINGS_CONTROL
/**
* GL: cashEntryType for the branch cash GL, controlEntryType for the account's own product's Current Control.
*
* <p>
* Mandatory when the product keeps accounts: a GL that cannot be resolved or posted throws, rolling the whole
* transaction back, so a balance can never move without its journal entry. It used to log and return, and a
* deposit whose Current Control lookup failed moved the balance with no entry at all.
*/
private void postTransactionGl(final CurrentAccount account, final CurrentAccountTransaction txn,
final JournalEntryType cashEntryType, final JournalEntryType controlEntryType, final String description) {
try {
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
if (product == null) return;
final Integer accountingType = product.getAccountingType();
if (accountingType == null || accountingType == 1) return; // NONE = 1
final GLAccount cashAccount = findGlAccount(account.getProductId(),
CashAccountsForSavings.SAVINGS_REFERENCE.getValue());
final GLAccount controlAccount = findGlAccount(account.getProductId(),
CashAccountsForSavings.SAVINGS_CONTROL.getValue());
if (cashAccount == null || controlAccount == null) {
log.warn("[GL] Missing GL account mapping for current account product id={}", account.getProductId());
return;
}
final String ref = "CAT" + txn.getId();
postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
} catch (Exception e) {
log.error("[GL] Failed GL posting for CA transaction id={}: {}", txn.getId(), e.getMessage(), e);
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
if (product == null) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.product.not.found",
"Current product " + account.getProductId() + " of account " + account.getAccountNo() + " not found.",
account.getId());
}
final Integer accountingType = product.getAccountingType();
if (accountingType == null || accountingType == 1) {
return; // NONE = 1: the product keeps no accounts
}
// Cash side: the branch cash GL from the Financial Activity mapping -- the same one the cashier screen uses.
// Liability side: the account's own current product's Current Control, read under its CURRENT product type.
final GLAccount cashAccount = cashGlResolver.resolve();
if (cashAccount == null) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped",
"No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.",
account.getId());
}
final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue());
if (controlAccount == null) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped",
"Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.",
account.getId());
}
final String ref = "CAT" + txn.getId();
postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description);
}
private GLAccount findGlAccount(final Long productId, final int financialAccountType) {
final var mapping = productToGLAccountMappingRepository
.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(
productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null);
productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null);
return mapping != null ? mapping.getGlAccount() : null;
}
@@ -60,13 +60,22 @@ public final class DormancyClock {
* "last live" date — a date that has nothing to do with this account, which has never been live
* at all.
*/
public static final String LAST_LIVE_ON = """
CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE
GREATEST(
sa.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
sa.activatedon_date))
END
""";
public static final String LAST_LIVE_ON = lastLiveOn("sa");
/**
* The same rule over any account table aliased {@code alias} that has {@code activatedon_date} and
* {@code client_id} — a current account ({@code m_current_account}) is measured exactly as a savings
* account is.
*/
public static String lastLiveOn(final String alias) {
return """
CASE WHEN %1$s.activatedon_date IS NULL THEN NULL ELSE
GREATEST(
%1$s.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = %1$s.client_id AND k.verified_on IS NOT NULL),
%1$s.activatedon_date))
END
""".formatted(alias);
}
}
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -63,6 +64,26 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
this.context.authenticatedUser();
// Savings and current accounts climb the same ladder (current ones on their own current product's days), so
// the report lists both, longest idle first across the two.
final List<DormantAccountData> accounts = new ArrayList<>(retrieve(subStatus, officeName, false));
accounts.addAll(retrieve(subStatus, officeName, true));
accounts.sort(Comparator.comparing(DormantAccountData::getDaysIdle, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(DormantAccountData::getBalance, Comparator.nullsLast(Comparator.reverseOrder())));
return accounts;
}
/**
* One kind of account. m_current_account and m_current_product carry the same column names as their savings
* counterparts, so the one query serves both with the tables swapped under the same aliases. DEAF remittances are
* recorded against savings accounts only, so a current account's DEAF columns come back null.
*/
private List<DormantAccountData> retrieve(final Integer subStatus, final String officeName, final boolean current) {
final String accountTable = current ? "m_current_account" : "m_savings_account";
final String productTable = current ? "m_current_product" : "m_savings_product";
final String deafJoin = current ? "left join m_deaf_remittance dr on 1 = 0 "
: "left join m_deaf_remittance dr on dr.savings_account_id = sa.id ";
final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON;
final StringBuilder sql = new StringBuilder();
@@ -76,13 +97,13 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
sql.append("dr.refunded_on as deafRefundedOn, ");
sql.append(lastCustomerTxn).append(" as lastCustomerTransactionDate, ");
sql.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" as daysIdle ");
sql.append("from m_savings_account sa ");
sql.append("from ").append(accountTable).append(" sa ");
sql.append("inner join m_client c on c.id = sa.client_id ");
sql.append("inner join m_office o on o.id = c.office_id ");
sql.append("inner join m_savings_product sp on sp.id = sa.product_id ");
sql.append("inner join ").append(productTable).append(" sp on sp.id = sa.product_id ");
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
// and the absence of one is exactly what the report has to show.
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
sql.append(deafJoin);
// An account belongs on these reports once the ladder has stamped it, or once the days
// have earned it — whichever comes first.
//
@@ -143,11 +164,17 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
// Longest idle first: those are the accounts nearest their next mark.
sql.append("order by daysIdle desc, sa.account_balance_derived desc");
return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(), params.toArray());
return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(current ? "CURRENT" : "SAVINGS"), params.toArray());
}
private static final class DormantAccountMapper implements RowMapper<DormantAccountData> {
private final String accountType;
DormantAccountMapper(final String accountType) {
this.accountType = accountType;
}
@Override
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final BigDecimal balance = rs.getBigDecimal("balance");
@@ -164,7 +191,8 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
final int stamped = rs.getInt("subStatus");
final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle);
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
return DormantAccountData.builder().accountType(accountType).accountId(rs.getLong("accountId"))
.accountNo(rs.getString("accountNo"))
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
.officeName(rs.getString("officeName")).productName(rs.getString("productName"))
// A null balance reads as zero rather than as missing data — an escheated account
@@ -95,6 +95,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private final VoucherService voucherService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final SponsorDDWritePlatformService sponsorDDWritePlatformService;
private final org.apache.fineract.portfolio.account.service.AccountBookDebitService accountBookDebitService;
// ── Maker step ─────────────────────────────────────────────────────────
@@ -612,17 +613,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO;
final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO;
final BigDecimal totalWithdrawal = faceValue.add(effectiveCharges).add(effectiveGst);
// A book debit, not a cash withdrawal: it posts no journal entry of its own, so the one
// DD entry below (Dr control / Cr DD Payable + charges + GST) is the only posting. A cash
// withdrawal posted Dr control / Cr Cash-at-Vault for cash that never moved, which then
// had to be hidden by flagging it reversed.
if (savingsAccountId != null) {
final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note);
resultingSavingsTransactionId = Long.valueOf(txnId);
resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal,
issuingBranchId, issuedDate, currentUser.getId());
drGlAccountId = getSavingsControlGlId(savingsAccountId);
markWithdrawalGlEntriesReversed("S" + txnId);
linkedAccountId = savingsAccountId;
} else {
final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note);
resultingCurrentTransactionId = Long.valueOf(txnId);
resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal,
issuingBranchId, issuedDate, currentUser.getId(), note);
drGlAccountId = getCurrentControlGlId(currentAccountId);
markWithdrawalGlEntriesReversed("CAT" + txnId);
linkedAccountId = currentAccountId;
}
if (drGlAccountId == null) {
@@ -911,17 +914,16 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
Long linkedAccountId = null;
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
// Book debit, no journal entry of its own -- see approveIssueRequest().
if (savingsAccountId != null) {
final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note);
resultingSavingsTransactionId = Long.valueOf(txnId);
resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal,
issuingBranchId, issuedDate, currentUser.getId());
drGlAccountId = getSavingsControlGlId(savingsAccountId);
markWithdrawalGlEntriesReversed("S" + txnId);
linkedAccountId = savingsAccountId;
} else {
final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note);
resultingCurrentTransactionId = Long.valueOf(txnId);
resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal,
issuingBranchId, issuedDate, currentUser.getId(), note);
drGlAccountId = getCurrentControlGlId(currentAccountId);
markWithdrawalGlEntriesReversed("CAT" + txnId);
linkedAccountId = currentAccountId;
}
if (drGlAccountId == null) {
@@ -1028,9 +1030,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId,
final BigDecimal ddLimitAmount) {
if (ddPayableGlAccountId == null) {
throw new IllegalArgumentException("Sponsor DD Payable GL account is required.");
}
// DD Payable GL is no longer configured on this screen -- it lives on each sponsor bank
// (m_sponsor_bank.dd_payable_gl_account_id) and is the only source DD posting actually reads
// (see findDdPayableGlAccountIdFor*). The column here is kept nullable for backward
// compatibility but must never be required, or a tenant that has never opened this dialog
// (payable NULL in the row) cannot save the charges/GST/cash mapping at all.
final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18);
final List<Map<String, Object>> existing = this.jdbcTemplate.queryForList(
@@ -1055,7 +1059,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "gst_payable_gl_account_id, dd_limit_amount, created_date) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, ddPayableGlAccountId);
if (ddPayableGlAccountId != null) ps.setLong(1, ddPayableGlAccountId); else ps.setNull(1, java.sql.Types.BIGINT);
if (chequeClearingGlAccountId != null) ps.setLong(2, chequeClearingGlAccountId); else ps.setNull(2, java.sql.Types.BIGINT);
if (cashGlAccountId != null) ps.setLong(3, cashGlAccountId); else ps.setNull(3, java.sql.Types.BIGINT);
if (chargesIncomeGlAccountId != null) ps.setLong(4, chargesIncomeGlAccountId); else ps.setNull(4, java.sql.Types.BIGINT);
@@ -1582,21 +1586,6 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
return result.getResourceId().toString();
}
/**
* A savings/current withdrawal auto-creates its own Dr SavingsControl / Cr CashVault journal entry —
* but the DD money isn't leaving as physical cash, it's becoming a liability to the sponsor bank.
* Hides that auto-created entry from GL reports (mirrors the same technique the Clerk Disbursement
* module uses for its own "Collection" flow) so only our combined Dr SavingsControl / Cr Sponsor DD
* Payable entry, posted via postGlOffsetEntry() right after this call, is visible.
*/
private void markWithdrawalGlEntriesReversed(final String transactionId) {
// Guarded by reversed = false: if transaction_id text is ever reused, this can only flip
// rows that are currently live, never re-touch an already-reversed unrelated transaction.
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
transactionId);
}
/** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */
private Long getSavingsControlGlId(final Long savingsAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
@@ -1612,7 +1601,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1",
currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
@@ -0,0 +1,383 @@
/**
* 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.JsonElement;
import com.google.gson.JsonObject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Admin screen backing API for TDS on deposit interest: the thresholds, rates and statutory
* references for each financial year, and the GL account deducted tax is credited to.
*
* Every TDS screen computes from what this returns. Nothing about the tax is compiled into the
* frontend any more, so a year that is not configured here is a year the screens refuse to compute
* rather than one they compute on stale figures.
*/
@Path("/v1/tdsconfig")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class TdsConfigApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "TDSCONFIG";
private static final Pattern FY_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})$");
private static final List<String> REGIMES = List.of("ITA_1961", "ITA_2025");
private static final List<String> DECLARATION_FORMS = List.of("15G", "15H", "121");
private static final List<String> FREQUENCIES = List.of("MONTHLY", "QUARTERLY");
/** GL roles, in the order the screen presents them. */
private static final Map<String, String[]> GL_ROLES = new LinkedHashMap<>();
static {
GL_ROLES.put("TDS_PAYABLE", new String[] { "TDS payable",
"Liability account that tax deducted from deposit interest is credited to, and held in until it is "
+ "remitted to the Government." });
}
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
// ─────────────────────────── read ───────────────────────────
/**
* Open to any signed-in user, not just holders of READ_TDSCONFIG: a teller's Form 121 tab and the
* branch TDS reports compute from these figures. Only the admin screen is gated on the permission.
*/
@GET
public String retrieveAll() {
this.context.authenticatedUser();
final Map<String, Object> out = new LinkedHashMap<>();
out.put("years", years());
out.put("glRoles", glRoles());
return gson.toJson(out);
}
/** Liability detail accounts, the only kind tax payable can sit in. */
@GET
@Path("template")
public String template() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT id, name, gl_code
FROM acc_gl_account
WHERE disabled = false AND account_usage = 1 AND classification_enum = 2
ORDER BY gl_code
""");
final List<Map<String, Object>> options = new ArrayList<>();
for (final Map<String, Object> a : accounts) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("id", a.get("id"));
o.put("name", a.get("name"));
o.put("glCode", a.get("gl_code"));
options.add(o);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccountOptions", options);
out.put("regimes", REGIMES);
out.put("declarationForms", DECLARATION_FORMS);
out.put("frequencies", FREQUENCIES);
return gson.toJson(out);
}
private List<Map<String, Object>> years() {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT * FROM m_tds_fy_config ORDER BY fy_label DESC");
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("regime", r.get("regime"));
o.put("interestTdsSection", r.get("interest_tds_section"));
o.put("thresholdSection", r.get("threshold_section"));
o.put("noPanSection", r.get("no_pan_section"));
o.put("defaultSection", r.get("default_section"));
o.put("declarationForm", r.get("declaration_form"));
o.put("declarationRule", r.get("declaration_rule"));
o.put("declarationSection", r.get("declaration_section"));
o.put("tdsStatementForm", r.get("tds_statement_form"));
o.put("tdsStatementRule", r.get("tds_statement_rule"));
o.put("certificateForm", r.get("certificate_form"));
o.put("declarationStatementFrequency", r.get("declaration_statement_frequency"));
o.put("declarationStatementDueDay", r.get("declaration_statement_due_day"));
o.put("thresholdGeneral", r.get("threshold_general"));
o.put("thresholdSenior", r.get("threshold_senior"));
o.put("ratePanPercentage", r.get("rate_pan_percentage"));
o.put("rateNoPanPercentage", r.get("rate_no_pan_percentage"));
o.put("basicExemption", r.get("basic_exemption"));
o.put("nilTaxCeiling", r.get("nil_tax_ceiling"));
o.put("roundTo", r.get("round_to"));
o.put("lateDeductionPercentage", r.get("late_deduction_percentage"));
o.put("lateDepositPercentage", r.get("late_deposit_percentage"));
o.put("seedDefault", r.get("is_seed_default"));
o.put("updatedOn", r.get("updated_on") == null ? null : String.valueOf(r.get("updated_on")));
out.add(o);
}
return out;
}
/** Every role, including one with nothing mapped - the screen must show the gap. */
private List<Map<String, Object>> glRoles() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.config_key, c.gl_account_id, a.name AS gl_account_name, a.gl_code
FROM m_tds_gl_config c
JOIN acc_gl_account a ON a.id = c.gl_account_id
""");
final Map<String, Map<String, Object>> byKey = new LinkedHashMap<>();
for (final Map<String, Object> r : rows) {
byKey.put((String) r.get("config_key"), r);
}
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map.Entry<String, String[]> role : GL_ROLES.entrySet()) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("configKey", role.getKey());
o.put("label", role.getValue()[0]);
o.put("description", role.getValue()[1]);
final Map<String, Object> mapped = byKey.get(role.getKey());
o.put("glAccountId", mapped == null ? null : mapped.get("gl_account_id"));
o.put("glCode", mapped == null ? null : mapped.get("gl_code"));
o.put("glAccountName", mapped == null ? null : mapped.get("gl_account_name"));
out.add(o);
}
return out;
}
// ─────────────────────────── write ───────────────────────────
/**
* Creates or replaces one financial year. The whole row is sent, so a save never leaves a year
* half old and half new. Saving clears the seed flag: the figures are now this bank's own.
*/
@PUT
@Path("year/{fy}")
@Consumes({ MediaType.APPLICATION_JSON })
public String upsertYear(@PathParam("fy") final String fy, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
validateFy(fy);
final JsonElement parsed = this.fromJsonHelper.parse(body);
final JsonObject root = parsed.getAsJsonObject();
final String regime = oneOf(root, "regime", REGIMES);
final String declarationForm = oneOf(root, "declarationForm", DECLARATION_FORMS);
final String frequency = oneOf(root, "declarationStatementFrequency", FREQUENCIES);
final int dueDay = integer(root, "declarationStatementDueDay", 1, 31);
final int roundTo = integer(root, "roundTo", 1, 100);
final BigDecimal thresholdGeneral = amount(root, "thresholdGeneral");
final BigDecimal thresholdSenior = amount(root, "thresholdSenior");
final BigDecimal basicExemption = amount(root, "basicExemption");
final BigDecimal nilTaxCeiling = amount(root, "nilTaxCeiling");
final BigDecimal ratePan = percentage(root, "ratePanPercentage");
final BigDecimal rateNoPan = percentage(root, "rateNoPanPercentage");
final BigDecimal lateDeduction = percentage(root, "lateDeductionPercentage");
final BigDecimal lateDeposit = percentage(root, "lateDepositPercentage");
// The no-PAN rate is a penalty rate. Below the ordinary rate it would reward a missing PAN.
if (rateNoPan.compareTo(ratePan) < 0) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig.nopan.rate.below.rate",
"The rate without PAN cannot be lower than the rate with PAN.");
}
final Object[] values = { regime, text(root, "interestTdsSection"), text(root, "thresholdSection"),
text(root, "noPanSection"), text(root, "defaultSection"), declarationForm, text(root, "declarationRule"),
text(root, "declarationSection"), text(root, "tdsStatementForm"), text(root, "tdsStatementRule"),
text(root, "certificateForm"), frequency, dueDay, thresholdGeneral, thresholdSenior, ratePan, rateNoPan,
basicExemption, nilTaxCeiling, roundTo, lateDeduction, lateDeposit, this.context.authenticatedUser().getId(),
fy };
final int updated = jdbcTemplate.update("""
UPDATE m_tds_fy_config
SET regime = ?, interest_tds_section = ?, threshold_section = ?, no_pan_section = ?,
default_section = ?, declaration_form = ?, declaration_rule = ?, declaration_section = ?,
tds_statement_form = ?, tds_statement_rule = ?, certificate_form = ?,
declaration_statement_frequency = ?, declaration_statement_due_day = ?,
threshold_general = ?, threshold_senior = ?, rate_pan_percentage = ?,
rate_no_pan_percentage = ?, basic_exemption = ?, nil_tax_ceiling = ?, round_to = ?,
late_deduction_percentage = ?, late_deposit_percentage = ?,
is_seed_default = false, updated_on = now(), updated_by = ?
WHERE fy_label = ?
""", values);
if (updated == 0) {
jdbcTemplate.update("""
INSERT INTO m_tds_fy_config
(regime, interest_tds_section, threshold_section, no_pan_section, default_section,
declaration_form, declaration_rule, declaration_section, tds_statement_form,
tds_statement_rule, certificate_form, declaration_statement_frequency,
declaration_statement_due_day, threshold_general, threshold_senior, rate_pan_percentage,
rate_no_pan_percentage, basic_exemption, nil_tax_ceiling, round_to,
late_deduction_percentage, late_deposit_percentage, created_by, fy_label,
is_seed_default, created_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, now())
""", values);
}
return retrieveAll();
}
/**
* Removes one year. The last year is kept: with none at all every TDS screen stops computing,
* which is a harsher outcome than an administrator deleting the wrong row expects.
*/
@DELETE
@Path("year/{fy}")
public String deleteYear(@PathParam("fy") final String fy) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tds_fy_config", Integer.class);
if (total != null && total <= 1) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig.last.year",
"The only financial year on record cannot be removed - edit it instead.");
}
final int removed = jdbcTemplate.update("DELETE FROM m_tds_fy_config WHERE fy_label = ?", fy);
if (removed == 0) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig.year.not.found",
"Financial year " + fy + " is not configured.");
}
return retrieveAll();
}
/** Sets, or with a null account clears, the GL account behind one role. */
@PUT
@Path("gl/{configKey}")
@Consumes({ MediaType.APPLICATION_JSON })
public String updateGlRole(@PathParam("configKey") final String configKey, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
if (!GL_ROLES.containsKey(configKey)) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig.unknown.role",
"'" + configKey + "' is not a known TDS GL role.");
}
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
final Long accountId = root.has("glAccountId") && !root.get("glAccountId").isJsonNull()
? root.get("glAccountId").getAsLong()
: null;
if (accountId == null) {
jdbcTemplate.update("DELETE FROM m_tds_gl_config WHERE config_key = ?", configKey);
return retrieveAll();
}
final Integer exists = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM acc_gl_account
WHERE id = ? AND disabled = false AND account_usage = 1 AND classification_enum = 2
""", Integer.class, accountId);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig.gl.account.invalid",
"GL account " + accountId + " was not found, is disabled, is a header, or is not a liability account.");
}
final Long userId = this.context.authenticatedUser().getId();
final int updated = jdbcTemplate.update(
"UPDATE m_tds_gl_config SET gl_account_id = ?, updated_on = now(), updated_by = ? WHERE config_key = ?",
accountId, userId, configKey);
if (updated == 0) {
jdbcTemplate.update(
"INSERT INTO m_tds_gl_config (config_key, gl_account_id, updated_on, updated_by) VALUES (?, ?, now(), ?)",
configKey, accountId, userId);
}
return retrieveAll();
}
// ─────────────────────────── validation ───────────────────────────
/** '2026-27': a four-digit opening year followed by the next year's last two digits. */
private static void validateFy(final String fy) {
final Matcher m = fy == null ? null : FY_PATTERN.matcher(fy);
if (m == null || !m.matches() || (Integer.parseInt(m.group(1)) + 1) % 100 != Integer.parseInt(m.group(2))) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig.fy.invalid",
"Financial year must look like 2026-27.");
}
}
private static String text(final JsonObject root, final String name) {
if (!root.has(name) || root.get(name).isJsonNull()) {
return null;
}
final String value = root.get(name).getAsString().trim();
return value.isEmpty() ? null : value;
}
private static String oneOf(final JsonObject root, final String name, final List<String> allowed) {
final String value = text(root, name);
if (value == null || !allowed.contains(value)) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid",
name + " must be one of " + allowed + ".");
}
return value;
}
private static BigDecimal number(final JsonObject root, final String name) {
if (!root.has(name) || root.get(name).isJsonNull() || root.get(name).getAsString().isBlank()) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".required", name + " is required.");
}
try {
return root.get(name).getAsBigDecimal();
} catch (NumberFormatException e) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid", name + " must be a number.");
}
}
private static BigDecimal amount(final JsonObject root, final String name) {
final BigDecimal value = number(root, name);
if (value.signum() < 0) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".negative", name + " cannot be negative.");
}
return value;
}
private static BigDecimal percentage(final JsonObject root, final String name) {
final BigDecimal value = number(root, name);
if (value.signum() < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range",
name + " must be between 0 and 100.");
}
return value;
}
private static int integer(final JsonObject root, final String name, final int min, final int max) {
final BigDecimal value = number(root, name);
if (value.stripTrailingZeros().scale() > 0 || value.intValue() < min || value.intValue() > max) {
throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range",
name + " must be a whole number between " + min + " and " + max + ".");
}
return value.intValue();
}
}
@@ -129,4 +129,24 @@
correctness. -->
<include file="parts/0571_add_sponsor_dd_gl_mapping_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0572_add_sponsor_bank_update_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0571_create_rbi_reserve_config.xml" relativeToChangelogFile="true"/>
<include file="parts/0572_create_oss_return_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml" relativeToChangelogFile="true"/>
<!-- 0576 widens m_rbi_reserve_rate.rate_type and MUST run before 0574 seeds SAVINGS_DEMAND. -->
<include file="parts/0576_widen_rbi_reserve_rate_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0574_rbi_reserve_ndtl_weighting.xml" relativeToChangelogFile="true"/>
<include file="parts/0575_rbi_reserve_custom_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0577_add_current_product_od_scheme_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0578_grant_cashier_read_codevalue.xml" relativeToChangelogFile="true"/>
<include file="parts/0579_current_product_gl_mappings_own_product_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0580_unique_core_product_gl_mapping.xml" relativeToChangelogFile="true"/>
<include file="parts/0581_form_a_completion.xml" relativeToChangelogFile="true"/>
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
never appeared on either side of this merge as a real conflict - the collision only became
visible once the stash was inspected). Renumbered to 0583 (the first number free after
dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file
name and this changeset's id were updated to match. -->
<include file="parts/0583_add_loan_originator_checker_permissions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,213 @@
<?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">
<!--
Configuration behind RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly).
Two things have to be configurable rather than compiled in:
- The RATES. CRR and SLR are set by RBI and change by circular, so a code edit per change
is not viable. They are effective-dated rather than overwritten: a return filed for an
earlier fortnight must keep reporting the rate that was actually in force then, which a
single mutable "current rate" column cannot do.
- The GL ACCOUNTS. This platform seeds two mutually exclusive charts of accounts, and
tenants restored from a bank's own books match neither exactly. Cash on hand is 100101
on one chart and 101001 on the other; balances with other banks are a single pair of
accounts on one and nineteen separate bank current accounts on the other. Any hardcoded
gl_code would read as zero on the charts it does not match - and a CRR return that
silently reports zero holdings is worse than one that refuses to compute.
Hence m_rbi_reserve_gl_config maps a ROLE to accounts, MANY accounts per role: a bank holds
current accounts at a dozen other banks, and every one of them counts toward the balances it
maintains. This is the one structural difference from m_clearing_gl_config, which is
deliberately one-account-per-role.
No role is seeded with an account, because the right account differs per tenant and a guess
would be indistinguishable from a deliberate choice. An unconfigured role makes the return
report that role as "not configured" rather than as zero.
Every changeSet is guarded so this migration is a safe no-op where already applied.
-->
<changeSet author="fineract" id="0571-1-create-rbi-reserve-rate">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_rbi_reserve_rate"/></not>
</preConditions>
<createTable tableName="m_rbi_reserve_rate">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- CRR (Form 1) or SLR (Form 9). -->
<column name="rate_type" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<!-- Percentage of DTL, e.g. 4.5000 for 4.5%. Four decimals because RBI has moved CRR in
steps as fine as 0.25%, and intermediate values are not ruled out. -->
<column name="percentage" type="DECIMAL(9,4)">
<constraints nullable="false"/>
</column>
<column name="effective_from" type="DATE">
<constraints nullable="false"/>
</column>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="created_by" type="BIGINT"/>
<!-- True only for the opening rows this migration seeds. RBI has moved CRR repeatedly,
so a seeded rate is a placeholder, not this bank's chosen figure. The returns label
any figure computed on one, rather than presenting a placeholder-derived required
amount as though somebody had confirmed it. Cleared the moment an administrator
saves that rate. -->
<column name="is_seed_default" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</createTable>
<!-- One rate per type per effective date; re-stating a rate for a date it already has is an
edit of that row, never a second row that a lookup would have to choose between.
Declared inside this changeSet so it is created with the table under the same guard -
split out, it would re-run ALTER TABLE ADD CONSTRAINT against a table that already has
it and abort the migration. -->
<addUniqueConstraint tableName="m_rbi_reserve_rate"
columnNames="rate_type, effective_from"
constraintName="uk_rbi_reserve_rate_type_date"/>
</changeSet>
<changeSet author="fineract" id="0571-3-create-rbi-reserve-gl-config">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_rbi_reserve_gl_config"/></not>
</preConditions>
<createTable tableName="m_rbi_reserve_gl_config">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- CASH_IN_HAND, BALANCE_WITH_OTHER_BANKS, DEMAND_TIME_LIABILITIES, SLR_INVESTMENTS. -->
<column name="config_key" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="gl_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
</createTable>
<!-- The same account must not be pinned to one role twice; it would be counted twice. -->
<addUniqueConstraint tableName="m_rbi_reserve_gl_config"
columnNames="config_key, gl_account_id"
constraintName="uk_rbi_reserve_gl_config_key_account"/>
<!-- Deleting a GL account that a return depends on must not leave a dangling reference that
would quietly drop an account's balance out of the maintained figure. -->
<addForeignKeyConstraint baseTableName="m_rbi_reserve_gl_config"
baseColumnNames="gl_account_id"
constraintName="fk_rbi_reserve_gl_config_account"
referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
<changeSet author="fineract" id="0571-5-seed-rbi-reserve-rates">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_rate"/>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_rbi_reserve_rate</sqlCheck>
</and>
</preConditions>
<!-- Opening values only, so the screens have something to show before an administrator sets
the bank's own. Dated far enough back that any return computed today finds a rate in
force; an administrator adds a newer effective-dated row when RBI moves either rate.
Flagged is_seed_default so they cannot pass for a confirmed figure. RBI has moved CRR
several times since this effective date, so a return computed on one of these is
reporting a placeholder - the screens and the export say so on the face of the return
rather than leaving a wrong required amount looking authoritative. -->
<insert tableName="m_rbi_reserve_rate">
<column name="rate_type" value="CRR"/>
<column name="percentage" valueNumeric="4.0000"/>
<column name="effective_from" valueDate="2020-01-01"/>
<column name="is_seed_default" valueBoolean="true"/>
</insert>
<insert tableName="m_rbi_reserve_rate">
<column name="rate_type" value="SLR"/>
<column name="percentage" valueNumeric="18.0000"/>
<column name="effective_from" valueDate="2020-01-01"/>
<column name="is_seed_default" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0571-6-rbi-reserve-permissions">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_permission"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG')
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_RBIRESERVECONFIG"/>
<column name="entity_name" value="RBIRESERVECONFIG"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_RBIRESERVECONFIG"/>
<column name="entity_name" value="RBIRESERVECONFIG"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0571-7-grant-rbi-reserve-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 ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG')
</sqlCheck>
</and>
</preConditions>
<!--
Granted to Super user and any manager-named role, matching 0511's grant of the other
organisation-setup permissions (institution profile, branch profile, clearing GL config).
Deciding which GL account a regulated figure is read from is a manager/admin act, not a
teller one.
Granted directly rather than by copying an existing permission's holders: a sibling-based
grant silently does nothing on a tenant where that sibling was never granted, which would
leave the screen and both returns invisible to every user, including admins.
-->
<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 ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG')
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
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>
@@ -0,0 +1,80 @@
<?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.
-->
<!--
Permission for the OSS off-site surveillance returns (OSS 1 - RAL, OSS 2 - RoE).
The returns read GL mappings out of m_rbi_reserve_gl_config, the table 0571 created: a return
line names a role, the role names its accounts, and the ledger decides the figure. No new table
is needed for that - the config_key column is a free-form role key, so the OSS roles live
alongside the CRR/SLR ones - and the bank's own particulars come from m_institution_profile.
So this changeset adds only the permission the endpoints check.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0572-1-oss-return-permission">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_permission"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OSSRETURN'
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="report"/>
<column name="code" value="READ_OSSRETURN"/>
<column name="entity_name" value="OSSRETURN"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0572-2-grant-oss-return-permission">
<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 = 'READ_OSSRETURN'
</sqlCheck>
</and>
</preConditions>
<!--
Granted to Super user and any manager-named role, matching 0571's grant of the reserve
return permissions. Granted directly rather than by copying an existing permission's
holders: a sibling-based grant silently does nothing on a tenant where that sibling was
never granted, which would leave both returns invisible to every user, including admins.
-->
<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 = 'READ_OSSRETURN'
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
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>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
m_sponsor_dd_gl_mapping.dd_payable_gl_account_id becomes nullable.
The DD Payable GL account moved to the sponsor bank in 0570, and posting reads it only from
m_sponsor_bank (findDdPayableGlAccountIdFor*), never from this row. The mapping screen no
longer shows or requires it. But the column was created NOT NULL, so a tenant whose row has
no payable (for example one that configures charges/GST/cash here without ever having set a global
payable) cannot save this mapping: the write fails at the NOT NULL constraint even though
nothing needs the value. Dropping the constraint lets the charges/GST/cash mapping be saved
on its own; the column is retained (not dropped) so any historical value stays readable.
-->
<changeSet author="fineract" id="0573-1-drop-not-null-sponsor-dd-mapping-payable">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_sponsor_dd_gl_mapping" />
<columnExists tableName="m_sponsor_dd_gl_mapping" columnName="dd_payable_gl_account_id" />
</and>
</preConditions>
<dropNotNullConstraint tableName="m_sponsor_dd_gl_mapping"
columnName="dd_payable_gl_account_id"
columnDataType="BIGINT" />
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,180 @@
<?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">
<!--
Brings Form 1 (CRR) and Form 9 (SLR) onto the basis RBI actually prescribes: a rate charged
on NET Demand and Time Liabilities, built from liabilities SPLIT by class, not on one flat
"DTL" total summed at face value.
0571 modelled DTL as a single role whose accounts are added at 100%. Three things RBI
requires cannot be expressed that way:
1. DEMAND vs TIME. Liabilities are reported under two separate heads. Current accounts are
demand; fixed deposits are time. A savings account is BOTH - RBI treats a prescribed
portion of savings balances as demand and the remainder as time. One flat total cannot
carry that split, and the split is not cosmetic: Form 1 reports the two heads
separately and a return that cannot fill them is not the return.
2. A PORTION, not the whole balance. The savings demand share is a percentage of the
account balance. m_rbi_reserve_gl_config has no column for it, so every mapped account
necessarily counted at 100% - overstating demand liabilities and understating time.
3. NET, not gross. CRR and SLR are charged on NDTL = DTL - inter-bank assets. Balances
with other banks were counted ONLY as CRR maintained; RBI also nets them off the base.
Counting them on one side but not the other overstates the required amount.
Hence two columns on the mapping table:
- weight_percentage: how much of an account's balance the role takes. Defaults to 100 so
every row 0571 already wrote keeps its present meaning exactly.
- liability_class: DEMAND or TIME, for the liability roles only. Null elsewhere, where the
distinction does not apply.
The savings split is carried as a RATE rather than a column default, because RBI sets it and
it moves like CRR and SLR do. It is effective-dated in the same table for the same reason: a
return refiled for an earlier fortnight must reproduce the split in force then.
Every changeSet is guarded so this migration is a safe no-op where already applied.
-->
<changeSet author="fineract" id="0574-1-add-weight-to-rbi-reserve-gl-config">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<not><columnExists tableName="m_rbi_reserve_gl_config" columnName="weight_percentage"/></not>
</and>
</preConditions>
<!--
How much of the account's balance this role takes, e.g. 46.5000 for the demand share of a
savings balance.
Defaults to 100 so existing mappings are untouched: before this column every mapped
account was summed whole, and that is what 100 means. A tenant that never opens the new
screen sees no change in any figure.
NOT NULL with a default rather than nullable-means-100: a null would have to be read as
100 at every use site, and one site that forgot would silently drop an account to zero.
-->
<addColumn tableName="m_rbi_reserve_gl_config">
<column name="weight_percentage" type="DECIMAL(9,4)" defaultValueNumeric="100.0000">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0574-2-add-liability-class-to-rbi-reserve-gl-config">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<not><columnExists tableName="m_rbi_reserve_gl_config" columnName="liability_class"/></not>
</and>
</preConditions>
<!--
DEMAND or TIME, for liability roles. Null for asset roles, where the distinction has no
meaning - a cash balance is neither.
Nullable rather than defaulted: an asset role has no correct value here, and defaulting
one would make "not applicable" indistinguishable from a deliberate choice.
The SAME account may appear twice under one role with different classes - a savings
control account contributes a demand slice and a time slice. That is why 0571's unique
constraint on (config_key, gl_account_id) is replaced below.
-->
<addColumn tableName="m_rbi_reserve_gl_config">
<column name="liability_class" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0574-3-widen-rbi-reserve-gl-config-uniqueness">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<columnExists tableName="m_rbi_reserve_gl_config" columnName="liability_class"/>
</and>
</preConditions>
<!--
0571 allowed an account once per role, to stop it being counted twice. That rule is still
right, but "once" now means once per CLASS: a savings account legitimately appears under
DEMAND_TIME_LIABILITIES twice - a demand slice and a time slice - and those are two
different figures on the return, not a double count.
Dropped and re-added rather than left alongside a wider one: the old constraint would
reject the second slice, which is the very row this migration exists to allow.
-->
<dropUniqueConstraint tableName="m_rbi_reserve_gl_config"
constraintName="uk_rbi_reserve_gl_config_key_account"/>
<addUniqueConstraint tableName="m_rbi_reserve_gl_config"
columnNames="config_key, gl_account_id, liability_class"
constraintName="uk_rbi_reserve_gl_config_key_account_class"/>
</changeSet>
<changeSet author="fineract" id="0574-4-backfill-liability-class">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_gl_config"/>
<columnExists tableName="m_rbi_reserve_gl_config" columnName="liability_class"/>
</and>
</preConditions>
<!--
Anything already mapped to the DTL role predates the split, so it carries no class. Left
null it would be counted in NDTL but reported under neither head, and the two halves of
the return would not add up to the total above them.
Backfilled to TIME rather than to DEMAND: understating demand liabilities is the
conservative error. Demand attracts the heavier treatment, so a wrong guess toward TIME
cannot make a bank look compliant when it is not. The screen flags every unclassified
row for an administrator to confirm.
-->
<sql>
UPDATE m_rbi_reserve_gl_config
SET liability_class = 'TIME'
WHERE config_key = 'DEMAND_TIME_LIABILITIES'
AND liability_class IS NULL
</sql>
</changeSet>
<changeSet author="fineract" id="0574-5-seed-savings-demand-split-rate">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_rate"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = 'SAVINGS_DEMAND'
</sqlCheck>
</and>
</preConditions>
<!--
The share of savings bank balances treated as a DEMAND liability; the remainder is time.
Seeded as a placeholder on the same footing as the CRR and SLR rates 0571 seeded, and
flagged the same way, so a return computed on it is labelled rather than presented as
settled. Dated to match those rows so any return computable today finds a split in force.
-->
<insert tableName="m_rbi_reserve_rate">
<column name="rate_type" value="SAVINGS_DEMAND"/>
<column name="percentage" valueNumeric="46.5000"/>
<column name="effective_from" valueDate="2020-01-01"/>
<column name="is_seed_default" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,105 @@
<?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">
<!--
Lets an administrator ADD liability and asset heads to Form 1 and Form 9 without a code
change.
0571 held the roles in a Java map, on the reasoning that a role only exists if code computes
something from it. That holds for the four structural roles - the DTL base, the maintained
side, the SLR investments - because each is wired into a specific place in the arithmetic.
It does not hold for the heads WITHIN those roles. A bank's liability base is not a fixed
list: reserve fund, tax payable, bills payable, sundry creditors, dividend payable - which of
these a bank carries, and what it calls them, differs per bank and changes as the chart of
accounts grows. Every such head enters the return the same way: a balance, weighted, under a
demand or time head, added into the base. Nothing about it needs new code, so requiring a
release to add one is a limitation with no reason behind it.
Hence this table. A custom head is a row; the arithmetic that consumes it already exists.
The four structural roles are NOT moved here. They stay in code because the computation
references them by name - NDTL nets BALANCE_WITH_OTHER_BANKS specifically - and a row an
administrator could rename or delete would break that reference silently.
-->
<changeSet author="fineract" id="0575-1-create-rbi-reserve-role">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_rbi_reserve_role"/></not>
</preConditions>
<createTable tableName="m_rbi_reserve_role">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- Referenced by m_rbi_reserve_gl_config.config_key, exactly as a built-in role is, so
the mapping table does not care whether a role is built in or user-defined. -->
<column name="config_key" type="VARCHAR(50)">
<constraints nullable="false" unique="true"
uniqueConstraintName="uk_rbi_reserve_role_config_key"/>
</column>
<column name="label" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="description" type="VARCHAR(500)"/>
<!-- Which return the head belongs to: RESERVE, OSS1, OSS2. -->
<column name="role_group" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<!--
How the return consumes the head, which is the whole reason a row is enough:
LIABILITY - added into the DTL base, under its demand/time class.
ASSET_NET - an asset netted OFF the base, as inter-bank assets are.
MAINTAINED - counted toward what is maintained against the requirement.
MEMO - shown on the return but entering no total.
Constrained to these four because each names a branch the arithmetic already has. A
free-text value would name a treatment no code implements, and the head would appear
on the return contributing to nothing.
-->
<column name="role_usage" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<!-- Ordering within its group on the screen and on the return. -->
<column name="position" type="INT" defaultValueNumeric="100">
<constraints nullable="false"/>
</column>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="created_by" type="BIGINT"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="0575-2-constrain-rbi-reserve-role-usage">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_rbi_reserve_role"/>
</preConditions>
<!-- Enforced in the database as well as the API: a row inserted by a migration or by hand
must not be able to name a treatment the return has no branch for. -->
<sql>
ALTER TABLE m_rbi_reserve_role
ADD CONSTRAINT ck_rbi_reserve_role_usage
CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'MEMO'))
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
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">
<!--
Widen m_rbi_reserve_rate.rate_type from VARCHAR(10) to VARCHAR(30).
0571 created the column as VARCHAR(10), sized for CRR and SLR. 0574 then seeds a
SAVINGS_DEMAND rate whose code is 14 characters, which overflows the column and aborts the
whole tenant upgrade before the row can be inserted. A tenant that already created the table
under 0571 keeps the narrow column until it is altered here, so the widening is its own
change (editing 0571 alone would not help, it never re-runs) and is ordered ahead of the
0574 seed in final-changelog-tenant.xml so the seed has room to land.
Only rate_type is widened. m_rbi_reserve_gl_config.liability_class (also VARCHAR(10)) takes
only DEMAND / TIME and never overflows, so it is left as 0574 created it.
-->
<changeSet author="fineract" id="0576-1-widen-rbi-reserve-rate-type">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_rate"/>
<columnExists tableName="m_rbi_reserve_rate" columnName="rate_type"/>
</and>
</preConditions>
<modifyDataType tableName="m_rbi_reserve_rate" columnName="rate_type" newDataType="VARCHAR(30)"/>
</changeSet>
</databaseChangeLog>
@@ -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.
-->
<!--
The overdraft section of a current product is the scheme each account's OD sanction is held to.
overdraft_limit already holds the ceiling - from here on read as an amount, not the percentage
the old form labelled it - so only the floor and the security rule are new:
od_min_limit the smallest limit an account on this scheme may be sanctioned
od_security_type CLEAN, SECURED or BOTH
2033 in the savings module adds the same two columns to m_savings_product_temp, where an edit
waits for its checker.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0577-1-current-product-od-scheme">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_current_product"/>
<not>
<columnExists tableName="m_current_product" columnName="od_min_limit"/>
</not>
</and>
</preConditions>
<addColumn tableName="m_current_product">
<column name="od_min_limit" type="DECIMAL(19,6)"/>
<column name="od_security_type" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
<!--
Excess drawing past the sanctioned limit. od_excess_allowed off (the default) means such a
withdrawal is refused; on, the excess is capped at od_excess_limit_percent of the limit and
carries od_penal_rate (% p.a.) on the excess alone.
-->
<changeSet author="fineract" id="0577-2-current-product-od-excess-drawing">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_current_product"/>
<not>
<columnExists tableName="m_current_product" columnName="od_excess_allowed"/>
</not>
</and>
</preConditions>
<addColumn tableName="m_current_product">
<column name="od_excess_allowed" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="od_excess_limit_percent" type="DECIMAL(19,6)"/>
<column name="od_penal_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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.
-->
<!--
The cashier's Transaction Entry screen reads the GLEntryType code values (the voucher entry
types) as soon as it opens. The Cashier role was never granted READ_CODEVALUE, so every cashier
login met "User has no authority to READ codevalues" on that screen. The Clerk role already has
READ_CODE and READ_CODEVALUE; this grants the same two read permissions to any cashier role.
Read-only: a cashier still cannot create or edit code values.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0578-1-grant-cashier-read-codevalue">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_role_permission"/>
<tableExists tableName="m_permission"/>
</and>
</preConditions>
<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 ('READ_CODE', 'READ_CODEVALUE')
AND LOWER(r.name) LIKE '%cashier%'
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>
@@ -0,0 +1,82 @@
<?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.
-->
<!--
Current products now map their GLs under their own product type, CURRENT (6), instead of SAVING (2).
Under SAVING the rows were keyed only by product id, so a current product and a savings product
with the same id shared them. On abc_bank, savings product 2 and current product 2 each wrote a
control row; every current-account deposit then found two Current Control rows, the lookup threw,
and the balance moved with no journal entry at all.
This moves the existing current-product rows to type 6:
1. A current product whose id no savings product uses owns every type-2 row with its id.
2. Where the ids collide, the rows cannot be told apart by owner, so they are split by age: each
product wrote its mappings as one batch when it was created, and the savings product came first
(on abc_bank ids 7-21 are the savings product's, 36-41 the current product's). The cutoff is the
later of the first rows of each duplicated mapping; rows after it move. Rounding adjustment (26)
is a savings-only mapping and never moves. Where no mapping is duplicated there is no cutoff and
nothing moves.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0579-1-current-product-mappings-no-collision">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="acc_product_mapping"/>
<tableExists tableName="m_current_product"/>
</and>
</preConditions>
<sql>
UPDATE acc_product_mapping SET product_type = 6
WHERE id IN (SELECT id FROM (
SELECT m.id FROM acc_product_mapping m
WHERE m.product_type = 2
AND m.product_id IN (SELECT cp.id FROM m_current_product cp)
AND m.product_id NOT IN (SELECT sp.id FROM m_savings_product sp)) x)
</sql>
</changeSet>
<changeSet author="fineract" id="0579-2-current-product-mappings-colliding-ids">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="acc_product_mapping"/>
<tableExists tableName="m_current_product"/>
</and>
</preConditions>
<sql>
UPDATE acc_product_mapping SET product_type = 6
WHERE id IN (SELECT id FROM (
SELECT m.id FROM acc_product_mapping m
WHERE m.product_type = 2
AND m.product_id IN (SELECT cp.id FROM m_current_product cp)
AND m.product_id IN (SELECT sp.id FROM m_savings_product sp)
AND m.financial_account_type &lt;&gt; 26
AND m.id &gt; (SELECT MAX(g.min_id) FROM (
SELECT d.product_id, d.financial_account_type, MIN(d.id) AS min_id
FROM acc_product_mapping d
WHERE d.product_type = 2 AND d.payment_type IS NULL AND d.charge_id IS NULL
GROUP BY d.product_id, d.financial_account_type
HAVING COUNT(*) &gt; 1) g
WHERE g.product_id = m.product_id)) x)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,62 @@
<?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.
-->
<!--
One product, one GL per role. A product (product_type + product_id) may map each core GL role
(financial_account_type) only once; payment-channel, per-charge and classification mappings carry
their own keys and are left out of the index.
Two rows for one role is how a current account's deposit once found two Current Control GLs and
posted nothing (0579 moved current products to their own type). With this index the database
refuses a second row outright, so no product can ever again read another product's GL.
Guarded: it is created only where no duplicate exists, so a tenant that still has one is not
blocked from starting. That tenant keeps working and simply lacks the index until the duplicate
is removed. Postgres only (partial index).
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0580-1-unique-core-product-gl-mapping" dbms="postgresql">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="acc_product_mapping"/>
<not>
<indexExists tableName="acc_product_mapping" indexName="uq_acc_product_mapping_core"/>
</not>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM (
SELECT 1 FROM acc_product_mapping
WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL
AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL
AND write_off_reason_id IS NULL
GROUP BY product_type, product_id, financial_account_type
HAVING COUNT(*) &gt; 1) d
</sqlCheck>
</and>
</preConditions>
<sql>
CREATE UNIQUE INDEX uq_acc_product_mapping_core
ON acc_product_mapping (product_type, product_id, financial_account_type)
WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL
AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL
AND write_off_reason_id IS NULL
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,386 @@
<?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">
<!--
Completes RBI Form A (the CRR return) against the statutory format.
What the return already did: demand/time split, inter-bank netting, NDTL, cash, balances
with RBI and with other banks, a configurable rate, and the reporting-Friday basis. What the
format also requires, and this migration adds the storage for:
- The remaining Part II asset heads (money at call, investments by category, advances by
category). These are REPORTED on the face of the return but are NOT all netted off the
base: only assets with the banking system are. They are therefore seeded as MEMO heads,
and an administrator who wants one netted changes its usage deliberately rather than the
seed deciding a regulatory question on the bank's behalf.
- The Memorandum, which Form A carries alongside the main statement: paid-up capital,
reserves, short- and long-term deposits, certificates of deposit.
- Annexure A (foreign currency liabilities and assets) and Annexure B (investments split
between approved and non-approved, shares, debentures, bonds). Both are seeded as MEMO:
a bank with no foreign-currency exposure reports them nil, which is a real answer, and
an account that does not exist simply stays unmapped.
- A filing lifecycle, because a return is not finished when it computes. RBI takes a
provisional Form A within 7 days of the fortnight ending and the final within 20, so a
bank has to know which fortnights are still open and which were already sent.
- An end date on a rate. Rates were open-ended from their effective date, which is right
for a rate still in force but cannot express one that was withdrawn without an immediate
successor.
Nothing here maps an account. The heads arrive empty for the same reason the original four
did: the right account differs per tenant, and a seeded guess is indistinguishable from a
deliberate choice.
Every changeSet is guarded so this migration is a safe no-op where already applied.
-->
<changeSet author="fineract" id="0581-1-rate-effective-to">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_rate"/>
<not><columnExists tableName="m_rbi_reserve_rate" columnName="effective_to"/></not>
</and>
</preConditions>
<!-- Null means "still in force", which is what every existing row is. A rate lookup already
takes the latest row effective on or before the date; this only lets a rate be closed
off explicitly where one lapsed with no successor. -->
<addColumn tableName="m_rbi_reserve_rate">
<column name="effective_to" type="DATE"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0581-2-create-form-a-filing">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_rbi_form_a_filing"/></not>
</preConditions>
<createTable tableName="m_rbi_form_a_filing">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- The fortnight this filing reports on, identified by its last day. -->
<column name="fortnight_end" type="DATE">
<constraints nullable="false"/>
</column>
<column name="reporting_date" type="DATE">
<constraints nullable="false"/>
</column>
<!--
DRAFT, GENERATED, CHECKED, APPROVED, PROVISIONAL_SUBMITTED, FINAL_SUBMITTED, REVISED.
A plain string rather than a code table: these are RBI's own stages, not values a
bank configures, and the API validates them.
-->
<column name="status" type="VARCHAR(30)" defaultValue="DRAFT">
<constraints nullable="false"/>
</column>
<!-- The figures as filed, so a submitted return keeps reporting what was actually sent
even after later backdated entries change what the ledger would now compute. -->
<column name="ndtl_amount" type="DECIMAL(19,6)"/>
<column name="required_amount" type="DECIMAL(19,6)"/>
<column name="maintained_amount" type="DECIMAL(19,6)"/>
<column name="average_maintained" type="DECIMAL(19,6)"/>
<column name="rate_percentage" type="DECIMAL(9,4)"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="submitted_on" type="DATE"/>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="created_by" type="BIGINT"/>
<column name="lastmodified_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="lastmodified_by" type="BIGINT"/>
</createTable>
<!-- One filing per fortnight. A revision edits that row and records the fact in its status,
rather than leaving two rows for one fortnight with no way to say which was sent. -->
<addUniqueConstraint tableName="m_rbi_form_a_filing"
columnNames="fortnight_end"
constraintName="uk_rbi_form_a_filing_fortnight"/>
</changeSet>
<changeSet author="fineract" id="0581-3-seed-form-a-asset-heads">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_%'
</sqlCheck>
</and>
</preConditions>
<!--
Part II asset heads, seeded as MEMO.
MEMO rather than ASSET_NET on purpose. Form A reports these, but CRR nets only assets
WITH THE BANKING SYSTEM off the base - money at call lent to banks qualifies, a house
loan does not. Seeding them as netted would quietly shrink NDTL by the bank's whole loan
book and understate the requirement. An administrator who establishes that a particular
head is a banking-system asset changes its usage; the seed does not decide it.
-->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_MONEY_AT_CALL"/>
<column name="label" value="Money at Call and Short Notice"/>
<column name="description" value="Form A Part II.4. Funds lent at call or short notice. Where lent to the banking system this is an NDTL deduction - change the usage to 'Asset - netted off the base' once that is established."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="200"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_INV_CENTRAL_GOVT"/>
<column name="label" value="Investments - Central Government Securities"/>
<column name="description" value="Form A Part II.5(a). Central government securities including treasury bills, at book value."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="201"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_INV_STATE_GOVT"/>
<column name="label" value="Investments - State Government Securities"/>
<column name="description" value="Form A Part II.5(b). State government securities, at book value."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="202"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_INV_OTHER_APPROVED"/>
<column name="label" value="Investments - Other Approved Securities"/>
<column name="description" value="Form A Part II.5(c). Other approved securities, at book value."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="203"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ADV_LOANS_CC_OD"/>
<column name="label" value="Advances - Loans, Cash Credits and Overdrafts"/>
<column name="description" value="Form A Part II.6(a). Advances to borrowers other than the banking system. Reported, not netted."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="204"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ADV_INLAND_BILLS"/>
<column name="label" value="Advances - Inland Bills Purchased and Discounted"/>
<column name="description" value="Form A Part II.6(b). Inland bills purchased and discounted."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="205"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ADV_FOREIGN_BILLS"/>
<column name="label" value="Advances - Foreign Bills Purchased and Discounted"/>
<column name="description" value="Form A Part II.6(c). Foreign bills purchased and discounted."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="206"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0581-4-seed-form-a-memorandum-heads">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_MEMO_%'
</sqlCheck>
</and>
</preConditions>
<!--
The Memorandum Form A carries alongside the main statement. Every head is MEMO by
definition: the memorandum states figures, it does not enter the CRR arithmetic.
-->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_MEMO_PAID_UP_CAPITAL"/>
<column name="label" value="Memorandum - Paid-up Capital"/>
<column name="description" value="Share capital paid up by members. Stated on the Memorandum to Form A."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="300"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_MEMO_RESERVES"/>
<column name="label" value="Memorandum - Reserves"/>
<column name="description" value="Statutory and other reserves. Stated on the Memorandum to Form A."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="301"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_MEMO_SHORT_TERM_DEPOSITS"/>
<column name="label" value="Memorandum - Short-term Time Deposits"/>
<column name="description" value="Time deposits of original maturity up to one year."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="302"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_MEMO_LONG_TERM_DEPOSITS"/>
<column name="label" value="Memorandum - Long-term Time Deposits"/>
<column name="description" value="Time deposits of original maturity over one year."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="303"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_MEMO_CERTIFICATES_OF_DEPOSIT"/>
<column name="label" value="Memorandum - Certificates of Deposit"/>
<column name="description" value="Certificates of deposit issued. Nil where the bank issues none."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="304"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0581-5-seed-form-a-annexure-heads">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_ANNEX%'
</sqlCheck>
</and>
</preConditions>
<!--
Annexure A (foreign currency) and Annexure B (investment detail).
Seeded even though a co-operative bank with no foreign business will report Annexure A
nil: nil is the answer the annexure asks for, and a section that is absent cannot be
distinguished from one that was overlooked.
-->
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ANNEXA_FC_LIABILITIES"/>
<column name="label" value="Annexure A - Foreign Currency Liabilities"/>
<column name="description" value="Liabilities denominated in foreign currency, reported in INR equivalent at the applicable reference rate. Nil where the bank has no foreign-currency business."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="400"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ANNEXA_FC_ASSETS"/>
<column name="label" value="Annexure A - Foreign Currency Assets"/>
<column name="description" value="Assets denominated in foreign currency, reported in INR equivalent at the applicable reference rate. Nil where the bank has no foreign-currency business."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="401"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ANNEXB_APPROVED_SECURITIES"/>
<column name="label" value="Annexure B - Investments in Approved Securities"/>
<column name="description" value="Investments in securities approved for SLR purposes."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="402"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ANNEXB_NON_APPROVED_SECURITIES"/>
<column name="label" value="Annexure B - Investments in Non-approved Securities"/>
<column name="description" value="Investments in securities not approved for SLR purposes."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="403"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ANNEXB_SHARES"/>
<column name="label" value="Annexure B - Shares"/>
<column name="description" value="Investments held in shares."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="404"/>
<column name="created_on" valueComputed="now()"/>
</insert>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_ANNEXB_DEBENTURES_BONDS"/>
<column name="label" value="Annexure B - Debentures and Bonds"/>
<column name="description" value="Investments held in debentures and bonds."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="MEMO"/>
<column name="position" valueNumeric="405"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0581-6-form-a-filing-permissions">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_permission"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING')
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_RBIFORMAFILING"/>
<column name="entity_name" value="RBIFORMAFILING"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_RBIFORMAFILING"/>
<column name="entity_name" value="RBIFORMAFILING"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0581-7-grant-form-a-filing-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 ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING')
</sqlCheck>
</and>
</preConditions>
<!-- Granted directly to Super user and manager-named roles, matching 0511 and 0571. A
sibling-based grant silently grants nothing where the sibling was never granted. -->
<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 ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING')
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
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>
@@ -0,0 +1,252 @@
<?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">
<!--
Configuration behind TDS on deposit interest: the thresholds, rates and statutory references
for each financial year, and the GL account deducted tax is credited to.
These used to be compiled into the frontend (FY_TAX_CONFIG). Every figure moves with the
annual Finance Act, so a code edit and a redeploy per change is not viable, and a year with
no entry stopped every TDS screen computing on 01 April.
One row per financial year rather than effective-dated rates: the law fixes TDS per year, and
a return filed for an earlier year must keep quoting the figures and provisions of that year.
Percentages are stored as percentages (10.0000 for 10%), the way an administrator enters them.
No GL account is seeded. The TDS payable account differs per chart of accounts, and a guess
would be indistinguishable from a deliberate choice.
-->
<changeSet author="fineract" id="0582-1-create-tds-fy-config">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_tds_fy_config"/></not>
</preConditions>
<createTable tableName="m_tds_fy_config">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- '2026-27'. -->
<column name="fy_label" type="VARCHAR(7)">
<constraints nullable="false" unique="true" uniqueConstraintName="uk_tds_fy_config_fy"/>
</column>
<!-- ITA_1961 or ITA_2025. -->
<column name="regime" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="interest_tds_section" type="VARCHAR(100)"/>
<column name="threshold_section" type="VARCHAR(100)"/>
<column name="no_pan_section" type="VARCHAR(100)"/>
<column name="default_section" type="VARCHAR(100)"/>
<!-- 15G, 15H or 121. -->
<column name="declaration_form" type="VARCHAR(5)">
<constraints nullable="false"/>
</column>
<column name="declaration_rule" type="VARCHAR(150)"/>
<column name="declaration_section" type="VARCHAR(100)"/>
<column name="tds_statement_form" type="VARCHAR(30)"/>
<column name="tds_statement_rule" type="VARCHAR(150)"/>
<column name="certificate_form" type="VARCHAR(30)"/>
<!-- MONTHLY or QUARTERLY. -->
<column name="declaration_statement_frequency" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="declaration_statement_due_day" type="INT">
<constraints nullable="false"/>
</column>
<column name="threshold_general" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="threshold_senior" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="rate_pan_percentage" type="DECIMAL(9,4)">
<constraints nullable="false"/>
</column>
<column name="rate_no_pan_percentage" type="DECIMAL(9,4)">
<constraints nullable="false"/>
</column>
<column name="basic_exemption" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="nil_tax_ceiling" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="round_to" type="INT" defaultValueNumeric="1">
<constraints nullable="false"/>
</column>
<column name="late_deduction_percentage" type="DECIMAL(9,4)">
<constraints nullable="false"/>
</column>
<column name="late_deposit_percentage" type="DECIMAL(9,4)">
<constraints nullable="false"/>
</column>
<!-- True only for the rows this migration seeds, so the screen can ask an administrator to
confirm them. Cleared the moment that year is saved. -->
<column name="is_seed_default" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="created_by" type="BIGINT"/>
<column name="updated_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="updated_by" type="BIGINT"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="0582-2-create-tds-gl-config">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_tds_gl_config"/></not>
</preConditions>
<createTable tableName="m_tds_gl_config">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- TDS_PAYABLE. One account per role. -->
<column name="config_key" type="VARCHAR(50)">
<constraints nullable="false" unique="true" uniqueConstraintName="uk_tds_gl_config_key"/>
</column>
<column name="gl_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_tds_gl_config_account"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
</column>
<column name="updated_on" type="TIMESTAMP WITH TIME ZONE"/>
<column name="updated_by" type="BIGINT"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="0582-3-seed-tds-fy-config">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_tds_fy_config"/>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tds_fy_config</sqlCheck>
</and>
</preConditions>
<!-- The two years the TDS screens already covered, carried over unchanged from the values
that were compiled into the frontend. Flagged is_seed_default until an administrator
confirms them. -->
<insert tableName="m_tds_fy_config">
<column name="fy_label" value="2025-26"/>
<column name="regime" value="ITA_1961"/>
<column name="interest_tds_section" value="section 194A"/>
<column name="threshold_section" value="section 194A(3)(i)"/>
<column name="no_pan_section" value="section 206AA"/>
<column name="default_section" value="section 201(1A)"/>
<column name="declaration_form" value="15G"/>
<column name="declaration_rule" value="Rule 29C"/>
<column name="declaration_section" value="section 197A"/>
<column name="tds_statement_form" value="Form 26Q"/>
<column name="tds_statement_rule" value="Rule 31A"/>
<column name="certificate_form" value="Form 16A"/>
<column name="declaration_statement_frequency" value="QUARTERLY"/>
<column name="declaration_statement_due_day" valueNumeric="15"/>
<column name="threshold_general" valueNumeric="50000"/>
<column name="threshold_senior" valueNumeric="100000"/>
<column name="rate_pan_percentage" valueNumeric="10"/>
<column name="rate_no_pan_percentage" valueNumeric="20"/>
<column name="basic_exemption" valueNumeric="400000"/>
<column name="nil_tax_ceiling" valueNumeric="1200000"/>
<column name="round_to" valueNumeric="1"/>
<column name="late_deduction_percentage" valueNumeric="1"/>
<column name="late_deposit_percentage" valueNumeric="1.5"/>
<column name="is_seed_default" valueBoolean="true"/>
</insert>
<insert tableName="m_tds_fy_config">
<column name="fy_label" value="2026-27"/>
<column name="regime" value="ITA_2025"/>
<column name="interest_tds_section" value="section 393(1) [Table Sl. 5(ii)/(iii)]"/>
<column name="threshold_section" value="section 393(4) [Table Sl. 7]"/>
<column name="no_pan_section" value="section 397(2)"/>
<column name="default_section" value="section 398"/>
<column name="declaration_form" value="121"/>
<column name="declaration_rule" value="Rule 211 of the Income-tax Rules, 2026"/>
<column name="declaration_section" value="section 393(6)"/>
<column name="tds_statement_form" value="Form 140"/>
<column name="tds_statement_rule" value="Rule 219 of the Income-tax Rules, 2026"/>
<column name="certificate_form" value="Form 131"/>
<column name="declaration_statement_frequency" value="MONTHLY"/>
<column name="declaration_statement_due_day" valueNumeric="7"/>
<column name="threshold_general" valueNumeric="50000"/>
<column name="threshold_senior" valueNumeric="100000"/>
<column name="rate_pan_percentage" valueNumeric="10"/>
<column name="rate_no_pan_percentage" valueNumeric="20"/>
<column name="basic_exemption" valueNumeric="400000"/>
<column name="nil_tax_ceiling" valueNumeric="1200000"/>
<column name="round_to" valueNumeric="1"/>
<column name="late_deduction_percentage" valueNumeric="1"/>
<column name="late_deposit_percentage" valueNumeric="1.5"/>
<column name="is_seed_default" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0582-4-tds-config-permissions">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_permission"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG')
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_TDSCONFIG"/>
<column name="entity_name" value="TDSCONFIG"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_TDSCONFIG"/>
<column name="entity_name" value="TDSCONFIG"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0582-5-grant-tds-config-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 ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG')
</sqlCheck>
</and>
</preConditions>
<!-- Same holders as the RBI reserve configuration (0571): Super user and manager roles. -->
<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 ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG')
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
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>
@@ -0,0 +1,79 @@
<?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">
<!-- The 5 LOAN_ORIGINATOR write permissions (CREATE/UPDATE/DELETE/ATTACH/DETACH_LOAN_ORIGINATOR)
were seeded with can_maker_checker=false and no corresponding "_CHECKER" permission rows.
AppUser.validateHasCheckerPermissionTo(permissionCode) (fineract-core/.../useradministration/
domain/AppUser.java) computes checkerPermissionName = permissionCode.toUpperCase() + "_CHECKER"
and requires the caller to hold either CHECKER_SUPER_USER or that exact permission code -
since m_role_permission.permission_id foreign-keys to m_permission(id), a "_CHECKER" row that
was never seeded can never be granted to any ordinary role. This means that even if
can_maker_checker were flipped on for these 5 permissions later, no non-super-user checker
could ever approve the resulting AWAITING_APPROVAL commands. This changeset only adds the
missing "_CHECKER" rows so the approval path is structurally ready; it deliberately does NOT
touch can_maker_checker on the base permissions (still false, unchanged). -->
<changeSet id="0583_add_loan_originator_checker_permissions" author="cbs-anshul">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_LOAN_ORIGINATOR_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_LOAN_ORIGINATOR_CHECKER"/>
<column name="entity_name" value="LOAN_ORIGINATOR"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_LOAN_ORIGINATOR_CHECKER"/>
<column name="entity_name" value="LOAN_ORIGINATOR"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DELETE_LOAN_ORIGINATOR_CHECKER"/>
<column name="entity_name" value="LOAN_ORIGINATOR"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="ATTACH_LOAN_ORIGINATOR_CHECKER"/>
<column name="entity_name" value="LOAN_ORIGINATOR"/>
<column name="action_name" value="ATTACH_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DETACH_LOAN_ORIGINATOR_CHECKER"/>
<column name="entity_name" value="LOAN_ORIGINATOR"/>
<column name="action_name" value="DETACH_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Granted to whichever roles already hold the corresponding base LOAN_ORIGINATOR permission
(confirmed live: Branch Manager and Clerk hold all 5 base write permissions) - the same
"grant to roles that already hold the base permission" convention already used for
UPDATE_SPONSORBANK (0572_add_sponsor_bank_update_permission.xml). -->
<changeSet id="0583_grant_loan_originator_checker_permissions" author="cbs-anshul">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT rp.role_id, checker.id
FROM m_role_permission rp
JOIN m_permission base ON base.id = rp.permission_id
JOIN m_permission checker ON checker.code = base.code || '_CHECKER'
WHERE base.code IN ('CREATE_LOAN_ORIGINATOR', 'UPDATE_LOAN_ORIGINATOR', 'DELETE_LOAN_ORIGINATOR',
'ATTACH_LOAN_ORIGINATOR', 'DETACH_LOAN_ORIGINATOR')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp2
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -49,8 +49,10 @@ public class SavingsProductAccountingDataValidator {
.notNull().integerGreaterThanZero();
}
// A current product maps no cash GL: its deposits and withdrawals post cash to the branch cash GL
// (Financial Activity mapping), so Savings Reference is validated there only when supplied.
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element)
|| ignoreExistenceValidation) {
|| (ignoreExistenceValidation && !accountType.equals(DepositAccountType.CURRENT_DEPOSIT))) {
final Long savingsReferenceAccountId = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue())
@@ -64,8 +66,11 @@ public class SavingsProductAccountingDataValidator {
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
}
// A current account pays no interest, so its product has no Interest On Savings expense account:
// validated there only when actually supplied.
final boolean paysInterest = !accountType.equals(DepositAccountType.CURRENT_DEPOSIT);
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element)
|| ignoreExistenceValidation) {
|| (ignoreExistenceValidation && paysInterest)) {
final Long interestOnSavingsAccountId = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue())
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.Setter;
@@ -36,4 +37,19 @@ public class CurrentOnlyEditFields {
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private Boolean odAccountClosedZeroBalanceInDayClosing;
@Column(name = "od_min_limit")
private BigDecimal odMinLimit;
@Column(name = "od_security_type")
private String odSecurityType;
@Column(name = "od_excess_allowed")
private Boolean odExcessAllowed;
@Column(name = "od_excess_limit_percent")
private BigDecimal odExcessLimitPercent;
@Column(name = "od_penal_rate")
private BigDecimal odPenalRate;
}
@@ -771,6 +771,26 @@ public final class ProductPendingEditFieldMapper {
f.setOdAccountClosedZeroBalanceInDayClosing(command.booleanObjectValueOfParameterNamed("odAccountClosedZeroBalanceInDayClosing"));
present.add("odAccountClosedZeroBalanceInDayClosing");
}
if (command.hasParameter("odMinLimit")) {
f.setOdMinLimit(command.bigDecimalValueOfParameterNamed("odMinLimit"));
present.add("odMinLimit");
}
if (command.hasParameter("odSecurityType")) {
f.setOdSecurityType(command.stringValueOfParameterNamed("odSecurityType"));
present.add("odSecurityType");
}
if (command.hasParameter("odExcessAllowed")) {
f.setOdExcessAllowed(command.booleanObjectValueOfParameterNamed("odExcessAllowed"));
present.add("odExcessAllowed");
}
if (command.hasParameter("odExcessLimitPercent")) {
f.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed("odExcessLimitPercent"));
present.add("odExcessLimitPercent");
}
if (command.hasParameter("odPenalRate")) {
f.setOdPenalRate(command.bigDecimalValueOfParameterNamed("odPenalRate"));
present.add("odPenalRate");
}
return present;
}
@@ -781,6 +801,11 @@ public final class ProductPendingEditFieldMapper {
putString(root, submitted, "avgBalanceCalcPeriod", f.getAvgBalanceCalcPeriod());
putString(root, submitted, "validForPeriod", f.getValidForPeriod());
putBoolean(root, submitted, "odAccountClosedZeroBalanceInDayClosing", f.getOdAccountClosedZeroBalanceInDayClosing());
putNumber(root, submitted, "odMinLimit", f.getOdMinLimit());
putString(root, submitted, "odSecurityType", f.getOdSecurityType());
putBoolean(root, submitted, "odExcessAllowed", f.getOdExcessAllowed());
putNumber(root, submitted, "odExcessLimitPercent", f.getOdExcessLimitPercent());
putNumber(root, submitted, "odPenalRate", f.getOdPenalRate());
}
public static Set<String> populateShareOnlyFields(final JsonCommand command, final ShareOnlyEditFields f) {
@@ -55,4 +55,5 @@
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2032_add_share_account_id_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2032_remove_withdrawal_facility.xml" relativeToChangelogFile="true" />
<include file="parts/2033_add_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
A current product edit waits in m_savings_product_temp as typed columns until a checker approves
it, then is replayed field by field. The OD scheme's two new fields (0577 on m_current_product)
need their own columns here too, or an approved edit silently drops them.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="2033-1">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_savings_product_temp" />
<not>
<columnExists tableName="m_savings_product_temp" columnName="od_min_limit" />
</not>
</and>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="od_min_limit" type="DECIMAL(19,6)" />
<column name="od_security_type" type="VARCHAR(20)" />
</addColumn>
</changeSet>
<changeSet author="fineract" id="2033-2">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_savings_product_temp" />
<not>
<columnExists tableName="m_savings_product_temp" columnName="od_excess_allowed" />
</not>
</and>
</preConditions>
<addColumn tableName="m_savings_product_temp">
<column name="od_excess_allowed" type="BOOLEAN" />
<column name="od_excess_limit_percent" type="DECIMAL(19,6)" />
<column name="od_penal_rate" type="DECIMAL(19,6)" />
</addColumn>
</changeSet>
</databaseChangeLog>