From b12268e740264f69dd86f13323e6ae035ce579e6 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 28 Sep 2026 13:35:32 +0530 Subject: [PATCH] feat: add RBI Form A and Form 9 SLR filing APIs, jobs, and migrations --- ...avingsProductToGLAccountMappingHelper.java | 7 + .../infrastructure/jobs/service/JobName.java | 1 + ...ccountMappingWritePlatformServiceImpl.java | 12 + .../api/BranchDayRegisterApiResource.java | 4 +- .../api/CashierDepositRequestApiResource.java | 26 +- .../api/CashReconciliationApiResource.java | 4 +- .../cashreport/api/CashReportApiResource.java | 31 +- .../ClerkWithdrawalRequestApiResource.java | 5 +- .../service/CountedCashLedger.java | 49 +++ .../service/DenominationBreakdownWriter.java | 8 +- .../api/RbiFormAFilingApiResource.java | 305 ++++++++++++++++++ .../api/RbiReserveConfigApiResource.java | 9 +- .../api/RbiReserveReturnApiResource.java | 84 +++-- .../SaveRbiFormAReturnJobConfig.java | 60 ++++ .../SaveRbiFormAReturnTasklet.java | 42 +++ .../service/RbiFormAAutoSaveService.java | 153 +++++++++ .../tenant/final-changelog-tenant.xml | 3 + .../tenant/parts/0582_form_a_saved_report.xml | 87 +++++ .../parts/0583_save_rbi_form_a_return_job.xml | 72 +++++ .../tenant/parts/0584_form9_slr_assets.xml | 118 +++++++ 20 files changed, 1020 insertions(+), 60 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java index 2fa7358c0..cc489048f 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java @@ -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 diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index ee5b62605..ba41bb64c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java index 35af580f8..ef33e9074 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java @@ -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: diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index cbfc2ee8e..31ac82030 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -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) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 592a66d32..5b71779ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -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 ); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index 8f3cb2437..19a757709 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -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 = ?) " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index edde51cf2..761ca117f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -60,34 +60,11 @@ public class CashReportApiResource { * * *

- * 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. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 57f9cb070..d579ec92e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -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 ); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java index 3fa7bcd11..d07b43810 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java @@ -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. + * + *

+ * 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. + * + *

+ * + *

+ * The transaction is found by id and account number together. 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. + * + *

+ * 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"; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index 3d22b4fdf..02a6498af 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -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" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java new file mode 100644 index 000000000..fdb524c9a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java @@ -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 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 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> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + final List> out = new ArrayList<>(); + for (final Map 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> 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 row = rows.get(0); + final Map 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 toSummary(final Map row) { + final Map 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java index 36b1a0d19..9b07890e5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -228,13 +228,18 @@ public class RbiReserveConfigApiResource { private static final List 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 ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO"); + private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET", + "MEMO"); private static final Map 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"); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java index 2f641343b..ad11ff8f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -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 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 daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles); + final Map 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 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 heads, final String usage) { + return heads.stream().anyMatch(h -> usage.equals(h.usage) && h.figure.configured); + } + private static BigDecimal totalOf(final List 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 dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId, - final List maintainedRoles) { + final List maintainedRoles, final BigDecimal required) { final List> days = new ArrayList<>(); BigDecimal total = BigDecimal.ZERO; BigDecimal lowest = null; @@ -771,7 +813,11 @@ public class RbiReserveReturnApiResource { } final Map 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java new file mode 100644 index 000000000..82b87cddc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java new file mode 100644 index 000000000..41ba2c192 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java @@ -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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java new file mode 100644 index 000000000..ab09e9143 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java @@ -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> 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); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index b8e38a310..56a28f9a0 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -132,5 +132,8 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml new file mode 100644 index 000000000..be760cbf3 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml new file mode 100644 index 000000000..50efebc34 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml @@ -0,0 +1,72 @@ + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Save RBI Form A Return' + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml new file mode 100644 index 000000000..650b39c02 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + 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')); + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key = 'FORMA_SLR_GOLD' + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role + WHERE config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', + 'FORMA_INV_OTHER_APPROVED') + + + + + + + + config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', 'FORMA_INV_OTHER_APPROVED') + AND role_usage = 'MEMO' + + + + +