feat: update TDS config, RBI reserve, current products, OSS returns, and dormancy services
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 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 Documentation build / build (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 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 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 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
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 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 Documentation build / build (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 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 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 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:
+2
@@ -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);
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
+9
@@ -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";
|
||||
|
||||
+10
@@ -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());
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
|
||||
+15
-1
@@ -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) {
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+112
-16
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
|
||||
+115
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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;
|
||||
|
||||
+6
-6
@@ -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()) //
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1658
-1618
File diff suppressed because it is too large
Load Diff
+152
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+141
-140
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+47
-86
@@ -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.
|
||||
@@ -642,8 +650,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);
|
||||
@@ -688,28 +697,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 +739,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)
|
||||
|
||||
@@ -1590,35 +1580,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 +1696,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();
|
||||
}
|
||||
|
||||
+55
-2
@@ -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.
|
||||
*
|
||||
|
||||
+850
@@ -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);
|
||||
}
|
||||
}
|
||||
+687
@@ -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();
|
||||
}
|
||||
}
|
||||
+892
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
+72
-10
@@ -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";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+101
-24
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+783
-764
File diff suppressed because it is too large
Load Diff
+18
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -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
|
||||
|
||||
+21
-32
@@ -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();
|
||||
}
|
||||
|
||||
+383
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -120,4 +120,17 @@
|
||||
<include file="parts/0572_add_renewal_grace_period_to_deposit_product_term.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0573_add_renewal_grace_period_to_saving_product_temp.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0574_add_renewal_grace_period_to_deposit_account_term.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"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+213
@@ -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>
|
||||
+80
@@ -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>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.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>
|
||||
+180
@@ -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>
|
||||
+105
@@ -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>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.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>
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<!--
|
||||
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>
|
||||
+50
@@ -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>
|
||||
+82
@@ -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 <> 26
|
||||
AND m.id > (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(*) > 1) g
|
||||
WHERE g.product_id = m.product_id)) x)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+62
@@ -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(*) > 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>
|
||||
+386
@@ -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>
|
||||
+252
@@ -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>
|
||||
+7
-2
@@ -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())
|
||||
|
||||
+16
@@ -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;
|
||||
}
|
||||
|
||||
+25
@@ -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) {
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+58
@@ -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>
|
||||
Reference in New Issue
Block a user