Merge branch 'dev-latest' into feature/bugfix
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-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 - MariaDB / test (test-core-2) (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 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:
2026-09-28 17:22:25 +05:30
20 changed files with 1020 additions and 60 deletions
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
// Interest Payable is editable on cash too, and optional. Interest posting routes
// through it on either basis, so a product can name one to carry interest declared
// but not yet credited; a product that names none still posts straight to Savings
// Control. Created-or-merged because a product configured before this row existed
// has no mapping to merge into.
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
break;
case ACCRUAL_PERIODIC:
// asset
@@ -73,6 +73,7 @@ public enum JobName {
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
; //
private final String name;
@@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
break;
case CASH_BASED:
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
// Interest Payable is saved on cash too, and optionally: interest posting routes
// through it on either basis (see AccountingProcessorHelper
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
// yet credited to the member reads as the bank's own liability rather than appearing
// only at the moment it is paid. Mapped only when the screen supplies it - a product
// naming none still posts straight to Savings Control, as it always did.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
element)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
}
break;
case ACCRUAL_PERIODIC:
@@ -200,7 +200,7 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
@@ -211,7 +211,7 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
@@ -192,7 +192,7 @@ public class CashierDepositRequestApiResource {
// reject() will delete these records so the vault position is reversed.
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource {
updatedLog, Timestamp.from(Instant.now()), id
);
// Rejection deleted this request's notes (the cash went back to the customer). Resubmitting
// means the cashier took the cash again, so the notes are recorded again -- without this an
// approved resubmission credited the account while the drawer never received its notes.
// Cleared first so a resubmit can never leave the old breakdown alongside the new one.
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")),
(String) record.get("account_no"), toLong(record.get("client_id")),
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
// Keep the central voucher register in sync with the edited amount
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
@@ -1004,9 +1015,14 @@ public class CashierDepositRequestApiResource {
*/
// Called at submit time — uses request id as temporary txn_id.
// approve() updates txn_id to real Fineract id; reject() deletes these records.
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
private void savePendingDenomination(final String denominationData,
final Long requestId, final Long savingsAccountId, final String accountNo,
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName,
final String txnDate) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -1037,10 +1053,14 @@ public class CashierDepositRequestApiResource {
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?)
""",
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// Dated on the request's own date, which is what the branch day's book figure reads.
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
// drawer on another and leave both days' cash wrong.
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
cashierId, cashierUsername, paymentTypeName
);
}
@@ -90,7 +90,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -120,7 +120,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
@@ -60,34 +60,11 @@ public class CashReportApiResource {
* </ul>
*
* <p>
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
* reads the same on all four.
* The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW},
* shared with the day register, the reconciliation screen and the withdrawal stock check, so a
* drawer reads the same on all four.
*/
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
// such transactions in this database were carrying ₹1.68 lakh that never existed.
//
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
// everything counted after it) out of the cashier's position.
//
// Compared per transaction inside a correlated subquery rather than as a join, so a
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id"
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW;
/**
* Day book — every voucher raised on one date, in one list, newest first.
@@ -722,10 +722,13 @@ public class ClerkWithdrawalRequestApiResource {
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'WITHDRAWAL', ?, ?, ?)
""",
txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// The request's own date, which the branch day's paid-out figure reads -- not the
// database clock, which put the notes on a different day from the withdrawal.
record.get("txn_date"),
cashier.getId(), cashier.getUsername(), paymentTypeName
);
}
@@ -87,4 +87,53 @@ public final class CountedCashLedger {
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
/**
* {@link #REAL_TXN_ID} plus the two tests a real id can still fail, as one predicate over the alias
* {@code std}. Every drawer figure reads this, not {@code REAL_TXN_ID} alone.
*
* <p>
* Only the cashier position report used to apply the two extra tests, so it and the reconciliation
* screen disagreed: a reversed ₹200 deposit left the settlement screen expecting three ₹100 notes
* while the settlement check, reading the position, allowed one — and refused the hand-over.
*
* <ul>
* <li>a breakdown whose savings transaction is reversed -- the money went back, so the notes did too.
* The table has no reversal marker of its own, so it is read off the transaction;
* <li>a breakdown that does not add up to its transaction -- ₹1,60,000 of notes against a ₹918
* deposit is mis-keyed, not cash. Sub-rupee tolerance, because a paisa-exact amount is handed
* over as the nearest whole rupee.
* </ul>
*
* <p>
* <b>The transaction is found by id and account number together.</b> Savings and current
* transactions keep separate id sequences, and a current-account deposit writes its own
* transaction id into {@code txn_id}. Matched on id alone, a ₹210 current-account deposit was
* judged against an unrelated savings transaction that happened to share its id, failed the
* amount test, and vanished from the cashier's drawer. A row with no account number (older
* writers) is still matched on id alone, as before.
*
* <p>
* All tests are correlated {@code NOT EXISTS} rather than joins, so a breakdown with no account
* transaction behind it (a Demand Draft, a manual GL entry) is untouched by them.
*/
public static final String COUNTED_ROW = " (NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt"
+ " JOIN m_savings_account rsa ON rsa.id = rt.savings_account_id"
+ " WHERE rt.id = std.txn_id"
+ " AND (std.account_no IS NULL OR rsa.account_no = std.account_no)"
+ " AND (rt.is_reversed = true OR " + breakdownDiffersFrom("rt.amount") + "))"
+ " AND NOT EXISTS ("
+ " SELECT 1 FROM m_current_account_transaction rct"
+ " JOIN m_current_account rca ON rca.id = rct.current_account_id"
+ " WHERE rct.id = std.txn_id AND rca.account_no = std.account_no"
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
+ " AND" + REAL_TXN_ID + ") ";
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
private static String breakdownDiffersFrom(final String amountColumn) {
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
}
}
@@ -286,7 +286,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -310,7 +310,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -407,7 +407,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -431,7 +431,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
@@ -0,0 +1,305 @@
/**
* 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 com.google.gson.JsonObject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.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;
/**
* Saved Form A / Form 9 returns: keeping a generated return so it can be read again later exactly as
* it was generated.
*
* A saved return is served from its stored snapshot, never recomputed. Recomputing would quietly
* produce a different statement from the one that was reviewed or filed - a backdated journal entry,
* a changed GL mapping or a corrected rate all move figures that were already reported - and a
* report whose figures change after it was filed is worse than no report at all.
*
* Deliberately not maker-checker: saving a snapshot changes no balance and posts no entry. The
* filing lifecycle (draft, checked, approved, submitted) is what carries the approval, and that is
* recorded on the row's status rather than in a separate command queue.
*/
@Path("/v1/rbiformafiling")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class RbiFormAFilingApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIFORMAFILING";
/**
* The filing lifecycle RBI's own timetable implies: a return is drafted, checked and approved
* inside the bank, sent provisionally within a week of the fortnight ending and finally within
* twenty days, and revised if a figure turns out wrong after that.
*/
private static final List<String> STATUSES = List.of("DRAFT", "GENERATED", "CHECKED", "APPROVED",
"PROVISIONAL_SUBMITTED", "FINAL_SUBMITTED", "REVISED");
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
/** Saved returns, newest first. The stored snapshot is left out - the list does not need it. */
@GET
public String retrieveAll(@QueryParam("formCode") final String formCode) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final StringBuilder sql = new StringBuilder("""
SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end,
f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained,
f.rate_percentage, f.remarks, f.submitted_on, f.created_on, u.username AS created_by_name
FROM m_rbi_form_a_filing f
LEFT JOIN m_appuser u ON u.id = f.created_by
""");
final List<Object> args = new ArrayList<>();
if (formCode != null && !formCode.isBlank()) {
sql.append(" WHERE f.form_code = ?");
args.add(formCode);
}
sql.append(" ORDER BY f.reporting_date DESC, f.id DESC");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
out.add(toSummary(row));
}
return gson.toJson(out);
}
/** One saved return, with the snapshot, so the screen can render exactly what was saved. */
@GET
@Path("{filingId}")
public String retrieveOne(@PathParam("filingId") final Long filingId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end,
f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained,
f.rate_percentage, f.remarks, f.submitted_on, f.created_on, f.report_json,
u.username AS created_by_name
FROM m_rbi_form_a_filing f
LEFT JOIN m_appuser u ON u.id = f.created_by
WHERE f.id = ?
""", filingId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
"Saved return " + filingId + " was not found.");
}
final Map<String, Object> row = rows.get(0);
final Map<String, Object> out = toSummary(row);
// The snapshot is embedded as JSON rather than as a string, so the screen receives the same
// shape the live endpoint returns and can render it through the same code path.
final String json = (String) row.get("report_json");
out.put("report", json == null || json.isBlank() ? null : gson.fromJson(json, JsonObject.class));
return gson.toJson(out);
}
/**
* Saves a generated return. Re-saving the same form for the same reporting date replaces that
* snapshot: regenerating a return for a date already saved is a correction of it, not a second
* statement for the same day.
*/
@POST
@Consumes({ MediaType.APPLICATION_JSON })
public String create(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String formCode = this.fromJsonHelper.extractStringNamed("formCode", root);
final LocalDate reportingDate = this.fromJsonHelper.extractLocalDateNamed("reportingDate", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
final Long officeId = this.fromJsonHelper.extractLongNamed("officeId", root);
if (formCode == null || formCode.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.form.code",
"The form being saved must be named.");
}
if (reportingDate == null) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.reporting.date",
"A reporting date is required.");
}
// The return itself, as the screen received it. Stored verbatim: the point of a saved return
// is that it does not change afterwards, so nothing here re-derives a figure from it.
final JsonObject report = root.getAsJsonObject().getAsJsonObject("report");
if (report == null) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.report",
"The generated return must be supplied with the save.");
}
final LocalDate periodStart = localDate(report, "fortnightStart");
final LocalDate periodEnd = localDate(report, "fortnightEnd");
final BigDecimal ndtl = decimal(report, "ndtlAmount");
final BigDecimal required = decimal(report, "requiredAmount");
final BigDecimal maintained = decimal(report, "maintainedAmount");
final BigDecimal rate = decimal(report, "ratePercentage");
BigDecimal average = null;
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
average = decimal(report.getAsJsonObject("maintenance"), "average");
}
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class,
formCode, java.sql.Date.valueOf(reportingDate));
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_rbi_form_a_filing
SET report_json = ?, ndtl_amount = ?, required_amount = ?, maintained_amount = ?,
average_maintained = ?, rate_percentage = ?, remarks = ?, office_id = ?,
period_start = ?, period_end = ?, fortnight_end = ?,
lastmodified_on = now(), lastmodified_by = ?
WHERE form_code = ? AND reporting_date = ?
""", gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId,
sqlDate(periodStart), sqlDate(periodEnd), sqlDate(periodEnd == null ? reportingDate : periodEnd),
this.context.authenticatedUser().getId(), formCode, java.sql.Date.valueOf(reportingDate));
} else {
jdbcTemplate.update("""
INSERT INTO m_rbi_form_a_filing
(form_code, reporting_date, fortnight_end, period_start, period_end, status,
report_json, ndtl_amount, required_amount, maintained_amount, average_maintained,
rate_percentage, remarks, office_id, created_on, created_by)
VALUES (?, ?, ?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?)
""", formCode, java.sql.Date.valueOf(reportingDate),
sqlDate(periodEnd == null ? reportingDate : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId,
this.context.authenticatedUser().getId());
}
return retrieveAll(formCode);
}
/**
* Moves a saved return along its filing lifecycle. The snapshot is untouched: a return that has
* been checked and submitted must still show the figures it was checked on.
*/
@POST
@Path("{filingId}/status")
@Consumes({ MediaType.APPLICATION_JSON })
public String updateStatus(@PathParam("filingId") final Long filingId, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final JsonElement root = this.fromJsonHelper.parse(body);
final String status = this.fromJsonHelper.extractStringNamed("status", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
if (status == null || !STATUSES.contains(status)) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.unknown.status",
"Status must be one of " + STATUSES + ".");
}
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE id = ?",
Integer.class, filingId);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
"Saved return " + filingId + " was not found.");
}
// The submission date is stamped when a return is actually sent, and only then - an earlier
// stage has nothing to date, and re-stamping on a later stage would lose when it was sent.
final boolean submitted = "PROVISIONAL_SUBMITTED".equals(status) || "FINAL_SUBMITTED".equals(status);
jdbcTemplate.update("""
UPDATE m_rbi_form_a_filing
SET status = ?,
remarks = COALESCE(?, remarks),
submitted_on = CASE WHEN ? THEN COALESCE(submitted_on, CURRENT_DATE) ELSE submitted_on END,
lastmodified_on = now(), lastmodified_by = ?
WHERE id = ?
""", status, remarks, submitted, this.context.authenticatedUser().getId(), filingId);
return retrieveAll(null);
}
/** Discards a saved return. Only ever removes the snapshot; nothing in the ledger is touched. */
@DELETE
@Path("{filingId}")
public String delete(@PathParam("filingId") final Long filingId) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
final Integer removed = jdbcTemplate.update("DELETE FROM m_rbi_form_a_filing WHERE id = ?", filingId);
if (removed == 0) {
throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found",
"Saved return " + filingId + " was not found.");
}
return retrieveAll(null);
}
private Map<String, Object> toSummary(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("id", row.get("id"));
out.put("formCode", row.get("form_code"));
out.put("reportingDate", str(row.get("reporting_date")));
out.put("periodStart", str(row.get("period_start")));
out.put("periodEnd", str(row.get("period_end")));
out.put("status", row.get("status"));
out.put("ndtlAmount", row.get("ndtl_amount"));
out.put("requiredAmount", row.get("required_amount"));
out.put("maintainedAmount", row.get("maintained_amount"));
out.put("averageMaintained", row.get("average_maintained"));
out.put("ratePercentage", row.get("rate_percentage"));
out.put("remarks", row.get("remarks"));
out.put("submittedOn", str(row.get("submitted_on")));
out.put("createdOn", str(row.get("created_on")));
out.put("createdBy", row.get("created_by_name"));
return out;
}
private static String str(final Object value) {
return value == null ? null : String.valueOf(value);
}
private static java.sql.Date sqlDate(final LocalDate date) {
return date == null ? null : java.sql.Date.valueOf(date);
}
private static BigDecimal decimal(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
return object.get(field).getAsBigDecimal();
}
private static LocalDate localDate(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
final String value = object.get(field).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
}
@@ -228,13 +228,18 @@ public class RbiReserveConfigApiResource {
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 List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
"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("MAINTAINED", "Counted toward CRR maintained (Form 1)");
// Separate from MAINTAINED because the two returns admit different assets: CRR is met in
// cash and balances, SLR in cash, gold and unencumbered approved securities. One usage for
// both would let a head meant for one return quietly satisfy the other.
USAGE_DESCRIPTIONS.put("SLR_ASSET", "Counted toward SLR maintained (Form 9)");
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
}
@@ -117,6 +117,13 @@ public class RbiReserveReturnApiResource {
private static final String USAGE_LIABILITY = "LIABILITY";
private static final String USAGE_ASSET_NET = "ASSET_NET";
private static final String USAGE_MAINTAINED = "MAINTAINED";
/**
* Counts toward SLR rather than CRR. The two are separate because the returns admit different
* assets: CRR is met in cash and balances with banks, SLR in cash, gold and unencumbered
* approved securities. A single "maintained" usage would let a head meant for one return
* quietly satisfy the other.
*/
private static final String USAGE_SLR_ASSET = "SLR_ASSET";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@@ -256,21 +263,29 @@ public class RbiReserveReturnApiResource {
// 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"));
// The maintenance table covers the whole calendar month, not the fortnight alone: a
// reviewer comparing two filings of the same month otherwise has to stitch two
// fortnights together by hand to see how the position moved across it.
final LocalDate fortnightStart = reportingDate.withDayOfMonth(1);
final LocalDate fortnightEnd = reportingDate.with(TemporalAdjusters.lastDayOfMonth());
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 Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles,
required);
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("periodStart", fortnightStart.toString());
daily.put("periodEnd", fortnightEnd.toString());
daily.put("periodLabel", fortnightStart.getMonth().getDisplayName(java.time.format.TextStyle.FULL,
java.util.Locale.ENGLISH) + " " + fortnightStart.getYear());
daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd));
daily.put("requiredAverage", required);
daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT);
@@ -398,31 +413,49 @@ public class RbiReserveReturnApiResource {
}
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd));
lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd));
// The heads that DO count toward SLR - gold, approved securities, eligible cash - each on
// its own line, so what makes up the maintained figure is visible rather than a single total
// a reader has to take on trust.
for (final CustomHead head : heads) {
if (USAGE_SLR_ASSET.equals(head.usage) && !inNamedSection(head)) {
lines.add(line(head.label, head.figure, 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));
if (USAGE_LIABILITY.equals(head.usage) || USAGE_ASSET_NET.equals(head.usage)
|| USAGE_SLR_ASSET.equals(head.usage) || inNamedSection(head)) {
continue;
}
// Every remaining head is a memo on THIS return, including one marked MAINTAINED: that
// usage means CRR, so the label must not suggest the figure was added into SLR.
lines.add(line(head.label + " (memo)", head.figure, monthEnd));
}
response.put("lines", lines);
final boolean computable = rate != null && dtl.configured && investments.configured;
// Form A's Memorandum and Annexures appear on Form 9 too - the same heads, under the same
// headings, so a reviewer reading either return finds them in the same place.
response.put("memorandum", sectionOf(heads, "Memorandum - "));
response.put("annexureA", sectionOf(heads, "Annexure A - "));
response.put("annexureB", sectionOf(heads, "Annexure B - "));
// Heads the bank declared as SLR assets - gold, approved securities, eligible cash. Summed
// here rather than on the built-in investments role alone, because SLR is maintained in
// more than government securities and a bank holding gold has no other place to state it.
final BigDecimal slrHeads = totalOf(heads, USAGE_SLR_ASSET);
final boolean anySlrAsset = investments.configured || hasUsage(heads, USAGE_SLR_ASSET);
final boolean computable = rate != null && dtl.configured && anySlrAsset;
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;
// A head marked MAINTAINED is deliberately NOT added: that usage means CRR, which is met
// in cash and balances with banks. Letting it count here would allow a head configured
// for Form 1 to inflate the SLR position - exactly the error this return exists to
// detect. Only SLR_ASSET heads and the built-in investments role count.
final BigDecimal held = investments.amount.add(slrHeads);
response.put("dtlAmount", liabilities);
response.put("demandLiabilities", dtl.configured ? demand : null);
response.put("timeLiabilities", dtl.configured ? time : null);
@@ -434,7 +467,11 @@ public class RbiReserveReturnApiResource {
response.put("compliant", held.compareTo(required) >= 0);
response.put("maintainedPercentage", percentageOf(held, ndtl));
}
response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments));
// The investments role is only reported as missing when nothing else stands in for it: a
// bank that maintains SLR in gold, or in securities mapped to its own heads, has configured
// the return even though that one built-in role is empty.
final List<String> slrUnmet = anySlrAsset ? unmet(rate, "SLR", dtl) : unmet(rate, "SLR", dtl, investments);
response.put("unmetRequirements", slrUnmet);
return gson.toJson(response);
}
@@ -645,6 +682,11 @@ public class RbiReserveReturnApiResource {
}
/** The total a set of custom heads contributes under one treatment. */
/** Whether any head was declared with one usage, so "nothing mapped" can be told from "nil". */
private static boolean hasUsage(final List<CustomHead> heads, final String usage) {
return heads.stream().anyMatch(h -> usage.equals(h.usage) && h.figure.configured);
}
private static BigDecimal totalOf(final List<CustomHead> heads, final String usage) {
BigDecimal total = BigDecimal.ZERO;
for (final CustomHead head : heads) {
@@ -753,7 +795,7 @@ public class RbiReserveReturnApiResource {
* 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<String> maintainedRoles, final BigDecimal required) {
final List<Map<String, Object>> days = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
BigDecimal lowest = null;
@@ -771,7 +813,11 @@ public class RbiReserveReturnApiResource {
}
final Map<String, Object> row = new LinkedHashMap<>();
row.put("date", day.toString());
// Each day carries the requirement it was measured against and the gap, so a reader can
// see WHY a day passed or failed without recomputing the percentage themselves.
row.put("required", required);
row.put("amount", held);
row.put("surplus", held.subtract(required));
days.add(row);
}
@@ -0,0 +1,60 @@
/**
* 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.jobs.saveformareturn;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class SaveRbiFormAReturnJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private RbiFormAAutoSaveService rbiFormAAutoSaveService;
@Bean
public SaveRbiFormAReturnTasklet saveRbiFormAReturnTasklet() {
return new SaveRbiFormAReturnTasklet(rbiFormAAutoSaveService);
}
@Bean
protected Step saveRbiFormAReturnStep() {
return new StepBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository)
.tasklet(saveRbiFormAReturnTasklet(), transactionManager).build();
}
@Bean
public Job saveRbiFormAReturnJob() {
return new JobBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository).start(saveRbiFormAReturnStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,42 @@
/**
* 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.jobs.saveformareturn;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Runs the Form A auto-save. The service decides whether today actually closes a fortnight, so the
* tasklet stays a thin trigger and the rule lives in one place.
*/
@RequiredArgsConstructor
public class SaveRbiFormAReturnTasklet implements Tasklet {
private final RbiFormAAutoSaveService service;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
service.saveCurrentFortnight();
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,153 @@
/**
* 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.service;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.rbireserve.api.RbiReserveReturnApiResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Keeps a copy of each fortnight's Form A without anyone having to remember to.
*
* Form 1 is filed twice a month, and what it reports is a position that cannot be recovered later: a
* backdated journal entry, a re-mapped GL account or a corrected rate all move figures that were
* already reported, so a fortnight nobody saved is a fortnight whose filed statement no longer
* exists anywhere. The job runs on the days the fortnight closes and stores the return as it stands
* at that close.
*
* It saves as DRAFT and nothing more. Checking, approving and submitting stay human acts - the job
* exists so the figures survive, not so a return files itself.
*
* It never overwrites: a fortnight already saved, by hand or by an earlier run, is left exactly as
* it is. Re-running the job must not replace a statement somebody has already reviewed.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RbiFormAAutoSaveService {
private static final String FORM_CODE = "FORM_1";
private final JdbcTemplate jdbcTemplate;
private final RbiReserveReturnApiResource returnApiResource;
private final Gson gson = new Gson();
/**
* Saves the fortnight that closes on the business date, if it has not been saved already.
*
* Scheduled for the 15th and the last day of each month, but it does not trust the schedule: it
* checks the date itself, so a mis-set cron or a manual run on the wrong day cannot file a
* fortnight that has not closed.
*/
@Transactional
public void saveCurrentFortnight() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate monthEnd = today.with(TemporalAdjusters.lastDayOfMonth());
final boolean fortnightCloses = today.getDayOfMonth() == 15 || today.equals(monthEnd);
if (!fortnightCloses) {
log.debug("RBI Form A auto-save: {} is not a fortnight end, nothing to save.", today);
return;
}
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class,
FORM_CODE, java.sql.Date.valueOf(today));
if (existing != null && existing > 0) {
log.info("RBI Form A auto-save: {} is already saved, leaving it untouched.", today);
return;
}
final JsonObject report;
try {
// Computed through the same endpoint the screen uses, so an auto-saved return and one
// saved by hand cannot come out differently.
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
} catch (final RuntimeException e) {
// A return that cannot be computed is not an error worth failing the batch over - an
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
// once it is filled. Logged rather than thrown so one tenant's gap does not stop others.
log.warn("RBI Form A auto-save: could not compute the return for {}: {}", today, e.getMessage());
return;
}
if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) {
log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.",
today);
return;
}
final LocalDate periodStart = date(report, "fortnightStart");
final LocalDate periodEnd = date(report, "fortnightEnd");
BigDecimal average = null;
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
average = decimal(report.getAsJsonObject("maintenance"), "average");
}
jdbcTemplate.update("""
INSERT INTO m_rbi_form_a_filing
(form_code, reporting_date, fortnight_end, period_start, period_end, status, report_json,
ndtl_amount, required_amount, maintained_amount, average_maintained, rate_percentage,
remarks, created_on)
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
""", FORM_CODE, java.sql.Date.valueOf(today),
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
"Saved automatically at the close of the fortnight.");
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
}
/** Offices the return could be scoped to. Unused today - the job saves the consolidated return. */
@SuppressWarnings("unused")
private List<Map<String, Object>> offices() {
return jdbcTemplate.queryForList("SELECT id, name FROM m_office ORDER BY id");
}
private static java.sql.Date sqlDate(final LocalDate date) {
return date == null ? null : java.sql.Date.valueOf(date);
}
private static BigDecimal decimal(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
return object.get(field).getAsBigDecimal();
}
private static LocalDate date(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
}
final String value = object.get(field).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
}
@@ -141,6 +141,9 @@
<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_form_a_saved_report.xml" relativeToChangelogFile="true"/>
<include file="parts/0583_save_rbi_form_a_return_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0584_form9_slr_assets.xml" relativeToChangelogFile="true"/>
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
@@ -0,0 +1,87 @@
<?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 a generated Form A be kept, so a filed return can be looked at again later exactly as it
was filed.
The summary columns 0581 added are enough to list saved returns, but not to reopen one: a
return is a statement, not five numbers, and reopening it has to show the same lines, the
same account breakdown and the same daily table that were on it when it was saved. Those
cannot be recomputed later and come out the same - a backdated journal entry, a changed GL
mapping or a corrected rate all move figures that were already reported. So the rendered
return is stored alongside the summary, and a saved report is served from that snapshot
rather than recomputed.
Keyed by (reporting date, form code) rather than by fortnight alone: Form 1 and Form 9 are
separate returns that can both be saved for dates in one month, and a bank re-running Form 1
for a different date within the same fortnight is saving a different statement, not
overwriting the earlier one.
-->
<changeSet author="fineract" id="0582-1-form-a-filing-report-json">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_form_a_filing"/>
<not><columnExists tableName="m_rbi_form_a_filing" columnName="report_json"/></not>
</and>
</preConditions>
<addColumn tableName="m_rbi_form_a_filing">
<!-- The whole computed return as it was served when saved. TEXT rather than a typed
column set: the return's shape is the API's own, and mirroring it in columns would
mean a migration every time a line is added to the statement. -->
<column name="report_json" type="TEXT"/>
<!-- FORM_1 or FORM_9. Both are saved through the same screen and the same table. -->
<column name="form_code" type="VARCHAR(20)" defaultValue="FORM_1">
<constraints nullable="false"/>
</column>
<column name="office_id" type="BIGINT"/>
<!-- Denormalised from the snapshot so the list can show them without parsing the JSON. -->
<column name="period_start" type="DATE"/>
<column name="period_end" type="DATE"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0582-2-form-a-filing-rekey-by-date-and-form">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_form_a_filing"/>
<columnExists tableName="m_rbi_form_a_filing" columnName="form_code"/>
</and>
</preConditions>
<!--
0581 made the fortnight alone unique, which was right while a filing was one row per
fortnight. Saving a rendered return makes that wrong: Form 1 and Form 9 both save against
dates in the same fortnight and would collide, and re-saving Form 1 for a different
reporting date inside one fortnight is a second statement rather than a replacement.
Dropped and replaced rather than added alongside: leaving the old constraint in place
would keep rejecting exactly the rows this change exists to allow.
-->
<dropUniqueConstraint tableName="m_rbi_form_a_filing" constraintName="uk_rbi_form_a_filing_fortnight"/>
<addUniqueConstraint tableName="m_rbi_form_a_filing"
columnNames="form_code, reporting_date"
constraintName="uk_rbi_form_a_filing_form_date"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,72 @@
<?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">
<!--
Saves each fortnight's Form A at the close of that fortnight.
What a Form A reports cannot be recovered afterwards: a backdated journal entry, a re-mapped
GL account or a corrected rate all move figures that were already reported, so a fortnight
nobody saved is one whose filed statement no longer exists anywhere. Leaving that to someone
remembering to press Save is leaving the bank's own record of what it filed to chance.
The cron fires at 23:30 on the 15th and on the last day of each month - the two days a
fortnight closes under RBI's reckoning. Late enough that the day's postings are in, and the
job re-checks the date itself rather than trusting the schedule, so a mis-set cron cannot
file a fortnight that has not closed.
It saves as DRAFT and stops there. Checking, approving and submitting remain human acts; the
job exists so the figures survive, not so a return files itself. It also never overwrites a
fortnight already saved, by hand or by an earlier run - re-running must not replace a
statement somebody has reviewed.
-->
<changeSet author="fineract" id="0583-1-save-rbi-form-a-return-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Save RBI Form A Return'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="Save RBI Form A Return"/>
<column name="display_name" value="Save RBI Form A Return"/>
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
<column name="short_name" value="RBIFRMA"/>
<!-- 23:30 on the 15th and on the last day of the month. "L" is Quartz's last-day-of-month
token, which is what makes one expression cover both 28th-of-February and 31st-of-
January without a month-by-month list. -->
<column name="cron_expression" value="0 30 23 15,L * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Save RBI Form A ReturnJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,118 @@
<?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">
<!--
Gives Form 9 the assets SLR is actually maintained in.
The statutory return is a statement of "cash, gold and unencumbered approved securities" -
three classes, not one. The return could previously only read a single built-in role for
government securities, so a bank holding gold had nowhere to state it and a bank holding
several categories of approved security had to collapse them into one figure.
Three changes:
- A new role usage, SLR_ASSET, separate from MAINTAINED. The two returns admit different
assets: CRR is met in cash and balances with banks, SLR in cash, gold and approved
securities. One usage for both would let a head configured for Form 1 inflate the SLR
position, which is the error the return exists to detect.
- A Gold head, which did not exist at all.
- The three approved-securities heads seeded by 0581 move from MEMO to SLR_ASSET. They
were memo only because nothing counted them; now something does. The advances and
money-at-call heads deliberately stay MEMO - they are reported on Form A, and neither is
an SLR asset.
What this does NOT do is decide eligibility. A security that is pledged or encumbered is not
SLR-eligible however it is classified, and a GL balance cannot express that - it needs a
security master holding each holding's eligibility and encumbrance. Until that exists, these
heads report what the ledger holds and an administrator maps only what is genuinely eligible.
Every changeSet is guarded so this migration is a safe no-op where already applied.
-->
<changeSet author="fineract" id="0584-1-widen-role-usage-constraint">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_rbi_reserve_role"/>
</preConditions>
<!--
0575 constrained role_usage to four values. SLR_ASSET is a fifth, so the check has to be
widened before any row can carry it - otherwise the inserts below are rejected by the
constraint rather than by anything meaningful.
Dropped and recreated rather than altered: a CHECK constraint has no in-place edit, and
recreating it keeps the guard rather than quietly leaving the column unconstrained.
-->
<sql>
ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage;
ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage
CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'MEMO'));
</sql>
</changeSet>
<changeSet author="fineract" id="0584-2-seed-gold-head">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key = 'FORMA_SLR_GOLD'
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_rbi_reserve_role">
<column name="config_key" value="FORMA_SLR_GOLD"/>
<column name="label" value="Gold held in India"/>
<column name="description" value="Gold held in India, valued as the applicable rules require. Counts toward SLR maintained on Form 9. Map the GL that carries the gold holding - the return reads its balance, so that balance must already be at the valuation the return is to report."/>
<column name="role_group" value="RESERVE"/>
<column name="role_usage" value="SLR_ASSET"/>
<column name="position" valueNumeric="210"/>
<column name="created_on" valueComputed="now()"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0584-3-securities-count-toward-slr">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_rbi_reserve_role"/>
<sqlCheck expectedResult="3">
SELECT COUNT(*) FROM m_rbi_reserve_role
WHERE config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT',
'FORMA_INV_OTHER_APPROVED')
</sqlCheck>
</and>
</preConditions>
<!--
Approved securities are what SLR is principally maintained in, so they count rather than
merely appear. Advances and money at call are left MEMO: both are reported on Form A and
neither is an SLR asset, and promoting them here would overstate the position.
-->
<update tableName="m_rbi_reserve_role">
<column name="role_usage" value="SLR_ASSET"/>
<where>
config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', 'FORMA_INV_OTHER_APPROVED')
AND role_usage = 'MEMO'
</where>
</update>
</changeSet>
</databaseChangeLog>