feat: add RBI Form A and Form 9 SLR filing APIs, jobs, and migrations
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:
+7
@@ -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
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+12
@@ -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:
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+23
-3
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 = ?) "
|
||||
|
||||
+4
-27
@@ -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.
|
||||
|
||||
+4
-1
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+49
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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"
|
||||
|
||||
+305
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+65
-19
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+153
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -132,5 +132,8 @@
|
||||
<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"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+87
@@ -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>
|
||||
+72
@@ -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>
|
||||
+118
@@ -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>
|
||||
Reference in New Issue
Block a user