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 ba41bb64c..93e097cc6 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
@@ -43,6 +43,7 @@ public enum JobName {
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
+ SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java
index 08b7cea5a..0c324de1e 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
"glAccountId", glAccountId);
}
}
+
+ /**
+ * Refuses to draw more out of a holding account than it holds.
+ *
+ *
+ * A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
+ * is the whole of what can come out. Drawing more leaves it with a negative balance, and a
+ * liability with a negative balance is not a small number - it says the bank owes less than
+ * nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
+ * and the figure is small enough to sit unnoticed inside a head that totals positive.
+ *
+ *
+ * Checked here rather than at each call site because the accounts this protects are reached by
+ * several features posting directly through JDBC, and a rule enforced in one of them is a rule
+ * the next one added will not have.
+ *
+ * @param glAccountId the account being drawn on.
+ * @param amount how much the caller wants to take out.
+ * @param what the account's role in the caller's own words, for the message a clerk reads.
+ */
+ public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
+ final java.math.BigDecimal amount, final String what) {
+ if (glAccountId == null || amount == null || amount.signum() <= 0) {
+ return;
+ }
+ // Credits less debits: these are liability-natured holding accounts, so the balance is what
+ // is still owed onward. Reversed entries and the entries they reverse both drop out - a
+ // reversal pair nets to nothing, and counting either side alone moves the balance by the
+ // whole amount.
+ final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
+ FROM acc_gl_journal_entry
+ WHERE account_id = ?
+ AND (reversed IS NULL OR reversed = false)
+ AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
+ """, java.math.BigDecimal.class, glAccountId);
+ final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
+ if (balance.compareTo(amount) < 0) {
+ throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
+ what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
+ + "difference would leave the account with a negative balance, which a "
+ + "holding account cannot have.",
+ "glAccountId", glAccountId, amount);
+ }
+ }
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java
index b5c375a61..1ee9fe013 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java
@@ -42,6 +42,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
+import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
@@ -666,30 +667,56 @@ public class AccountingProcessorHelper {
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
.orElse(payableAccount);
- // Stage 1: the expense is recognised against whatever the liability sits in right now -- the
- // named Payable account if the product has one, or straight against Savings Control if it
- // doesn't. This was missing entirely: every product, configured or not, posted zero journal
- // entries for interest until this leg existed, since a product with no Payable/Paid names
- // configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
- // to post in their place.
- postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
+ // A product that names no payable falls back to Savings Control above. The chain below would
+ // then open with Dr Savings Control / Cr Interest On Savings and close with its exact
+ // reverse, the two cancelling: the expense never charged and the member never credited. Such
+ // a product has no liability to draw down, so it keeps the single pair it always posted.
+ if (payableAccount.getId().equals(controlAccount.getId())) {
+ postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
+ amount, isReversal);
+ return;
+ }
+
+ // The interest is charged to expense and raised as a liability first, for whatever part of
+ // it no accrual has already covered.
+ //
+ // The chain below draws the payable DOWN, so it assumes an accrual already credited it for
+ // the period. Where none ran - a product posting on cash accounting, or a period the accrual
+ // job never covered - the payable went negative by the interest and the expense netted to
+ // nothing across the next two legs, leaving the charge off the P&L entirely. A liability
+ // account cannot hold a negative balance: the bank does not owe less than nothing.
+ //
+ // So the shortfall is raised here rather than assumed away. Where the accrual DID run, the
+ // payable already carries the interest and this leg writes nothing - the balance below is
+ // enough, and raising it again would double the liability.
+ //
+ // Only a liability payable is topped up. A bank that maps an expense GL as the payable books
+ // the cost there directly: payableBalance reads credit minus debit, so an expense's normal
+ // debit balance would come back negative and every posting would raise a shortfall larger
+ // than the interest itself.
+ final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
+ final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
+ final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
+ if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
+ // Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
+ postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
+ shortfall, false);
+ }
+
+ // Out of the payable, into the interest-paid account the product names.
+ postSavingsLeg(office, currencyCode, payableAccount, interestOnSavingsAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
- // The interest expense is not raised again here. It was just raised above, and this posting
- // only settles that recognised debt onward to where it is actually paid out from -- raising
- // the expense a second time would put the same interest on the P&L twice.
- //
- // Stage 2: the liability moves from the payable to the account it is paid out of. Where the
- // product names no paid account both resolve to the payable and this says nothing, so it is
- // skipped and the payout comes straight out of the payable.
- final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
- if (!payableIsPaid) {
- postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
+ // A separate paid account, where the product names one, sits between interest-paid and the
+ // member; otherwise the payout leaves straight from interest-paid.
+ final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
+ if (hasSeparatePaidAccount) {
+ postSavingsLeg(office, currencyCode, interestOnSavingsAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
- // Stage 3: into the member's own balance.
- final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
+ // Into the member's own balance.
+ final GLAccount payoutFromAccount = hasSeparatePaidAccount ? paidAccount : interestOnSavingsAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
@@ -733,6 +760,38 @@ public class AccountingProcessorHelper {
*
* @return the account, or null where the product does not configure this name
*/
+ /**
+ * What the interest payable account currently holds for this office, on its own liability side.
+ *
+ *
+ * Read so an interest posting can tell an accrual that already ran from one that did not. The
+ * posting chain draws the payable down; where nothing had raised it, the account went negative
+ * and the expense netted to nothing, so the charge never reached the P&L at all. The caller
+ * raises only the shortfall, which is nil when the accrual did run.
+ *
+ *
+ * Credits less debits, because a payable is a liability: the balance is what the bank still
+ * owes. Floored at zero by the caller rather than here - a negative balance is a real state to
+ * report, and the caller's arithmetic needs the true figure, not a clamped one.
+ */
+ private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) {
+ if (payableAccount == null || payableAccount.getId() == null) {
+ return BigDecimal.ZERO;
+ }
+ // Reversed entries and the entries they reverse are both excluded: a reversal pair nets to
+ // nothing, and counting either side alone would move the balance by the whole amount.
+ final BigDecimal balance = jdbcTemplate.queryForObject("""
+ SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
+ FROM acc_gl_journal_entry
+ WHERE account_id = ?
+ AND office_id = ?
+ AND currency_code = ?
+ AND (reversed IS NULL OR reversed = false)
+ AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
+ """, BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode);
+ return balance == null ? BigDecimal.ZERO : balance;
+ }
+
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
if (savingsProductId == null) {
return null;
diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java
index 5092daed6..82d124e58 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java
@@ -180,18 +180,21 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
- // createCashBasedJournalEntriesAndReversalsForSavings already uses for isReversal -- keeps
- // the two in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on
- // the cash-based path.
+ // createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
+ // in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
+ // cash-based path.
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
if (amountSign != 0) {
final boolean isClawback = amountSign < 0;
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
final boolean effectiveIsReversal = isReversal ^ isClawback;
- this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
- AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
- savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, postingAmount,
- effectiveIsReversal);
+ // Routed through the same helper the cash processor uses, so a posting states
+ // the whole chain the product is configured for -- out of the payable, through
+ // the interest-paid account, into the member's balance -- rather than the single
+ // payable-to-control pair that used to be here, which skipped interest-paid
+ // entirely and left the expense off the P&L.
+ this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
+ savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
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 31ac82030..71920ecff 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
@@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
+import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
+import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -73,6 +75,8 @@ public class BranchDayRegisterApiResource {
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final VaultPositionService vaultPositionService;
+ private final SavingsInterestPostingReportService interestPostingReportService;
+ private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
@@ -589,6 +593,34 @@ public class BranchDayRegisterApiResource {
"Business day " + businessDate + " is already closed.");
}
+ // ── This branch's savings interest reports must be approved before it closes ──
+ // Not overridable by a force-close reason: the branch posts its own customers' interest when
+ // it closes, and a report nobody approved has nothing to post, so they would go unpaid.
+ final List interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
+ if (!interestPending.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
+ "Cannot close the day — this branch's savings interest posting report is not approved for: "
+ + String.join(", ", interestPending)
+ + ". Approve it under Reports → Interest Posting Reports, then close the day.");
+ }
+
+ // ── The head office closes last ──
+ // Every branch that opened the day closes first and posts its own interest; a branch that
+ // never opened is not waited for.
+ final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
+ if (isHeadOffice) {
+ final List stillOpen = jdbcTemplate.queryForList("""
+ SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
+ WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ?
+ ORDER BY o.name
+ """, String.class, Date.valueOf(businessDate), officeId);
+ if (!stillOpen.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
+ "Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
+ + ". The head office closes last.");
+ }
+ }
+
// ── Ensure every cashier holding cash has been reconciled AND settled ──
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
// and if so, verify reconciliation and settlement records exist for the business date.
@@ -687,6 +719,28 @@ public class BranchDayRegisterApiResource {
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
+ // ── The branch posts its own approved savings interest as it closes ──
+ // The head office also posts what it approved for branches that did not open today. Entries
+ // are raised in the customers' own branch either way.
+ //
+ // Posted here, before the close is written, and synchronously, so the outcome is never
+ // silent: each report posts in a transaction of its own and commits, and if any fails the
+ // close is refused with the reason — the day stays open, the report stays APPROVED, and
+ // closing again once the cause is fixed posts it.
+ final List toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
+ if (isHeadOffice) {
+ toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
+ }
+ if (!toPost.isEmpty()) {
+ log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
+ final List interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
+ if (!interestFailures.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
+ "Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
+ + ". The report is still approved; fix the cause and close the day again.");
+ }
+ }
+
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
// this write are two separate statements, so two managers pressing Close at once (or one
// double-click) both got past the check. The second UPDATE then overwrote the first's
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java
index dc9690edf..da1a79dc7 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java
@@ -709,6 +709,21 @@ public class ClerkShareWithdrawalRequestApiResource {
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
}
+ // The Share Sundry account the redemption draws on must actually hold the money.
+ //
+ // collectionApplied above says A collection was applied to THIS account, not that the
+ // proceeds are still sitting in Sundry - the same holding account serves every share
+ // account, and an earlier redemption may already have drawn it down. Approving past that
+ // takes Sundry negative, which is a liability owing less than nothing, and the overdraw
+ // lands inside a balance-sheet head that totals positive, where it does not look wrong.
+ final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares);
+ if (redemptionValue != null) {
+ final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
+ shareAccountId);
+ GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue,
+ "The Share Sundry (Suspense) account");
+ }
+
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
@@ -958,6 +973,33 @@ public class ClerkShareWithdrawalRequestApiResource {
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
* reset id sequence in a dev environment can violate.
*/
+ /**
+ * What a redemption of this many shares will draw out of Share Sundry.
+ *
+ *
+ * Priced at the unit price the member's own shares were issued at, which is the price the
+ * redemption itself uses. The product's nominal price is not consulted: a member who subscribed
+ * at an earlier price redeems at theirs, and pricing the guard differently from the posting
+ * would either block a redemption that fits or let one through that does not.
+ *
+ * @return the value, or null where no price can be established - the guard then stands aside
+ * rather than blocking on a figure it had to invent.
+ */
+ private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) {
+ if (shareAccountId == null || requestedShares == null || requestedShares <= 0) {
+ return null;
+ }
+ // The purchase that issued the holding - type 500. Its unit price is what the member paid.
+ final BigDecimal unitPrice = jdbcTemplate
+ .queryForList("""
+ SELECT unit_price FROM m_share_account_transaction
+ WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
+ ORDER BY id DESC LIMIT 1
+ """, BigDecimal.class, shareAccountId)
+ .stream().findFirst().orElse(null);
+ return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares));
+ }
+
private Long redeemDebitGlOf(final Long shareTransactionId) {
if (shareTransactionId == null) {
return null;
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
index fdb524c9a..184a4f990 100644
--- 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
@@ -166,12 +166,23 @@ public class RbiFormAFilingApiResource {
"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");
+ // The statutory form states its figures per column inside Part B rather than once at the
+ // top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be
+ // read out of it. Without this a filed statutory return listed as a row of dashes, and the
+ // list could not be scanned for the one return somebody was looking for.
+ final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull()
+ && "FORM_1_STATUTORY".equals(report.get("formCode").getAsString());
+
+ // The statutory form's period runs between its two fortnight closes, which is what the
+ // saved list prints as the row's period.
+ final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart");
+ final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd");
+ // The current fortnight's figures are the second column - the close the form is made up to.
+ final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount");
+ final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount");
+ final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained")
+ : decimal(report, "maintainedAmount");
+ final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : decimal(report, "ratePercentage");
BigDecimal average = null;
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
average = decimal(report.getAsJsonObject("maintenance"), "average");
@@ -302,4 +313,40 @@ public class RbiFormAFilingApiResource {
final String value = object.get(field).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
+
+ /**
+ * One of a statutory part's per-column figures, taken at the column the form is made up to.
+ *
+ * The form carries three columns and the saved list has one cell per figure, so it takes the
+ * second - the close the return reports. Index 1 rather than "the last": the third column is
+ * the base the ratios are charged on, not a later position, and listing it would show a
+ * requirement against liabilities from a fortnight nobody filed.
+ */
+ private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
+ if (!report.has(part) || !report.get(part).isJsonObject()) {
+ return null;
+ }
+ final JsonObject partObject = report.getAsJsonObject(part);
+ if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
+ return null;
+ }
+ final var values = partObject.getAsJsonArray(field);
+ if (values.size() < 2 || values.get(1).isJsonNull()) {
+ return null;
+ }
+ return values.get(1).getAsBigDecimal();
+ }
+
+ /** One of the statutory form's column dates, for the period a saved row reports. */
+ private static LocalDate columnDate(final JsonObject report, final int index) {
+ if (!report.has("columns") || !report.get("columns").isJsonArray()) {
+ return null;
+ }
+ final var columns = report.getAsJsonArray("columns");
+ if (columns.size() <= index || columns.get(index).isJsonNull()) {
+ return null;
+ }
+ final String value = columns.get(index).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 9b07890e5..3b40943d3 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
@@ -199,6 +199,7 @@ public class RbiReserveConfigApiResource {
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
+ ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)");
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
if (role.startsWith("OSS1_")) {
@@ -227,9 +228,15 @@ 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. */
+ /**
+ * The treatments a head can be given, each naming a branch the returns already have.
+ *
+ * ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive
+ * at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be
+ * subtracted from the liabilities of a return that has no such base.
+ */
private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
- "MEMO");
+ "ASSET", "MEMO");
private static final Map USAGE_DESCRIPTIONS = new LinkedHashMap<>();
static {
@@ -243,6 +250,27 @@ public class RbiReserveConfigApiResource {
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
}
+ /**
+ * How a head is consumed, in the terms of the return it belongs to.
+ *
+ * LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the
+ * head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no
+ * base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL
+ * wording would tell a Form IX filer their head feeds a calculation that does not exist on
+ * their return.
+ */
+ private static String usedBy(final String group, final String usage) {
+ if ("FORM9".equals(group)) {
+ return switch (usage == null ? "" : usage) {
+ case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities";
+ case "ASSET" -> "Form IX — an asset; enters Total Assets";
+ case "MEMO" -> "Form IX — stated on the form but entering no total";
+ default -> usage;
+ };
+ }
+ return USAGE_DESCRIPTIONS.getOrDefault(usage, usage);
+ }
+
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
private static final Map BUILT_IN_USAGE = new LinkedHashMap<>();
static {
@@ -390,11 +418,14 @@ public class RbiReserveConfigApiResource {
o.put("label", row.get("label"));
o.put("group", group);
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
- o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
+ o.put("usedBy", usedBy(group, usage));
o.put("builtIn", false);
o.put("usage", usage);
- // A liability head carries the demand/time split; nothing else does.
- o.put("classified", "LIABILITY".equals(usage));
+ // A liability head carries the demand/time split; nothing else does. Not on Form IX,
+ // where the demand and time heads are separate lines of the form itself - asking for a
+ // class on top of that would let a head declared under 5(a) Fixed Deposits be marked
+ // DEMAND, which the form has no way to report.
+ o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group));
o.put("roleId", row.get("id"));
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
out.add(o);
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 ad11ff8f9..b8e2c98a7 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
@@ -30,10 +30,13 @@ import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -138,15 +141,23 @@ public class RbiReserveReturnApiResource {
@GET
@Path("form1")
public String form1(@QueryParam("reportingDate") final String reportingDateParam,
- @QueryParam("officeId") final Long officeId) {
+ @QueryParam("baseDate") final String baseDateParam, @QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
- final LocalDate reportingDate = parseOrBusinessDate(reportingDateParam);
+ final LocalDate reportingDate = requirePastOrPresent(parseOrBusinessDate(reportingDateParam),
+ "The reporting date");
final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS);
// A reporting Friday that falls on a holiday has no close of business to strike a balance
// at, so the position is taken at the preceding working day instead.
final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate);
- final LocalDate dtlDate = precedingWorkingDay(nominalDtlDate);
+ // The base date can be given outright. The 45-day lag lands where it lands, and a bank
+ // reading an earlier fortnight - or correcting a filing - needs to name the date the base
+ // is struck at rather than work backwards from it. Given explicitly it is used as given;
+ // otherwise it is derived from the lag as before.
+ final boolean baseGiven = baseDateParam != null && !baseDateParam.isBlank();
+ final LocalDate dtlDate = baseGiven
+ ? requirePastOrPresent(LocalDate.parse(baseDateParam), "The base date")
+ : precedingWorkingDay(nominalDtlDate);
final Map response = new LinkedHashMap<>();
response.put("formCode", "FORM_1");
@@ -155,8 +166,10 @@ public class RbiReserveReturnApiResource {
response.put("reportingDate", reportingDate.toString());
response.putAll(fortnightOf(reportingDate));
- // The lag basis, spelled out so a reviewer can check it rather than trust it.
- final boolean shifted = !dtlDate.equals(nominalDtlDate);
+ // The lag basis, spelled out so a reviewer can check it rather than trust it. A base date
+ // the filer chose is said to be theirs: the note must never imply the lag produced a date
+ // somebody typed.
+ final boolean shifted = !baseGiven && !dtlDate.equals(nominalDtlDate);
final Map basis = new LinkedHashMap<>();
basis.put("lagDays", DTL_LAG_DAYS);
basis.put("rawLagDate", rawLagDate.toString());
@@ -164,13 +177,18 @@ public class RbiReserveReturnApiResource {
basis.put("dtlAsOnDate", dtlDate.toString());
basis.put("holdingsAsOnDate", reportingDate.toString());
basis.put("shiftedForHoliday", shifted);
- basis.put("note", "DTL is taken as at the reporting Friday on or before " + rawLagDate + " ("
- + DTL_LAG_DAYS + " days before the reporting date)"
- + (shifted
- ? "; that Friday (" + nominalDtlDate + ") was a holiday, so the position is taken at the "
- + "preceding working day " + dtlDate
- : "")
- + "; balances maintained are taken as at " + reportingDate + ".");
+ basis.put("baseDateChosen", baseGiven);
+ basis.put("note", baseGiven
+ ? "DTL is taken as at " + dtlDate + ", the base date entered for this return; balances maintained "
+ + "are taken as at " + reportingDate + "."
+ : "DTL is taken as at the reporting Friday on or before " + rawLagDate + " (" + DTL_LAG_DAYS
+ + " days before the reporting date)"
+ + (shifted
+ ? "; that Friday (" + nominalDtlDate
+ + ") was a holiday, so the position is taken at the preceding working day "
+ + dtlDate
+ : "")
+ + "; balances maintained are taken as at " + reportingDate + ".");
response.put("basis", basis);
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId);
@@ -207,25 +225,32 @@ public class RbiReserveReturnApiResource {
final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO);
final List> lines = new ArrayList<>();
- lines.add(line("Demand and Time Liabilities (DTL)", dtl, dtlDate));
+ lines.add(withComparison(line("Demand and Time Liabilities (DTL)", dtl, dtlDate),
+ "DEMAND_TIME_LIABILITIES", dtlDate, reportingDate, officeId));
for (final CustomHead head : heads) {
if (USAGE_LIABILITY.equals(head.usage)) {
- lines.add(line(head.label, head.figure, dtlDate));
+ lines.add(withComparison(line(head.label, head.figure, dtlDate), head.figure.configKey, dtlDate,
+ reportingDate, officeId));
}
}
// The two heads the liability total is built from, each stated on the face of the return as
// RBI reports them rather than left implicit inside the total above.
lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate));
lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate));
- lines.add(line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate));
+ lines.add(withComparison(
+ line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate),
+ "BALANCE_WITH_OTHER_BANKS", dtlDate, reportingDate, officeId));
for (final CustomHead head : heads) {
if (USAGE_ASSET_NET.equals(head.usage)) {
- lines.add(line("Less: " + head.label, head.figure, dtlDate));
+ lines.add(withComparison(line("Less: " + head.label, head.figure, dtlDate), head.figure.configKey,
+ dtlDate, reportingDate, officeId));
}
}
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate));
- lines.add(line("Cash in hand — counts toward CRR maintained", cash, reportingDate));
- lines.add(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate));
+ lines.add(withComparison(line("Cash in hand — counts toward CRR maintained", cash, reportingDate),
+ "CASH_IN_HAND", dtlDate, reportingDate, officeId));
+ lines.add(withComparison(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate),
+ "BALANCE_WITH_OTHER_BANKS", dtlDate, reportingDate, officeId));
for (final CustomHead head : heads) {
// A head belonging to the Memorandum or an Annexure prints under that section's own
// heading below, not here: listing it in both places would show one figure twice and
@@ -234,11 +259,16 @@ public class RbiReserveReturnApiResource {
continue;
}
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) {
- lines.add(line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure,
- reportingDate));
+ lines.add(withComparison(
+ line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure,
+ reportingDate),
+ head.figure.configKey, dtlDate, reportingDate, officeId));
}
}
response.put("lines", lines);
+ // Named so the screen can head its two columns without re-deriving either date.
+ response.put("baseDate", dtlDate.toString());
+ response.put("currentDate", reportingDate.toString());
final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured);
response.put("computable", computable);
@@ -355,8 +385,15 @@ public class RbiReserveReturnApiResource {
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
- final LocalDate requested = parseOrBusinessDate(reportingDateParam);
- final LocalDate monthEnd = requested.with(TemporalAdjusters.lastDayOfMonth());
+ final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam),
+ "The reporting date");
+ final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth());
+ // Run inside the current month, the month end has not arrived. Reading balances at it
+ // returned today's figures under the month-end heading, so a month still running reported
+ // as a closed one - with nothing on the return to say the month was not over.
+ final LocalDate today = DateUtils.getBusinessLocalDate();
+ final LocalDate monthEnd = minimum(nominalMonthEnd, today);
+ final boolean monthComplete = !nominalMonthEnd.isAfter(today);
final Map response = new LinkedHashMap<>();
response.put("formCode", "FORM_9");
@@ -364,13 +401,22 @@ public class RbiReserveReturnApiResource {
response.put("frequency", "Monthly");
response.put("requestedDate", requested.toString());
response.put("reportingDate", monthEnd.toString());
+ // Stated so a return read before the month closes cannot be mistaken for the filed one.
+ response.put("monthEnd", nominalMonthEnd.toString());
+ response.put("monthComplete", monthComplete);
response.put("monthStart", requested.withDayOfMonth(1).toString());
final Map basis = new LinkedHashMap<>();
basis.put("dtlAsOnDate", monthEnd.toString());
basis.put("holdingsAsOnDate", monthEnd.toString());
- basis.put("note", "SLR is reported on the month-end position: both the DTL base and the "
- + "SLR-eligible investments are taken as at " + monthEnd + ".");
+ basis.put("note", monthComplete
+ ? "SLR is reported on the month-end position: both the DTL base and the "
+ + "SLR-eligible investments are taken as at " + monthEnd + "."
+ // Said plainly rather than left to the dates: a provisional return that reads like a
+ // filed one is the whole risk here.
+ : "The month ending " + nominalMonthEnd + " has not closed. Both the DTL base and "
+ + "the SLR-eligible investments are taken as at " + monthEnd
+ + ", the position so far, and will move before the month ends.");
response.put("basis", basis);
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId);
@@ -540,6 +586,10 @@ public class RbiReserveReturnApiResource {
figure.configured = true;
for (final Map account : accounts) {
+ // The mapping row, not the account. One account can be pinned to a role twice - a
+ // savings balance split into its demand and time slices - and the comparison below
+ // matches rows across the two dates by this, so the two slices stay apart.
+ final Long mappingId = ((Number) account.get("mapping_id")).longValue();
final Long accountId = ((Number) account.get("id")).longValue();
final int classification = ((Number) account.get("classification_enum")).intValue();
final String glCode = (String) account.get("gl_code");
@@ -559,6 +609,7 @@ public class RbiReserveReturnApiResource {
if (disabled || header) {
figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)"));
final Map skipped = new LinkedHashMap<>();
+ skipped.put("mappingId", mappingId);
skipped.put("glAccountId", accountId);
skipped.put("glCode", glCode);
skipped.put("name", account.get("name"));
@@ -607,6 +658,7 @@ public class RbiReserveReturnApiResource {
}
final Map detail = new LinkedHashMap<>();
+ detail.put("mappingId", mappingId);
detail.put("glAccountId", accountId);
detail.put("glCode", glCode);
detail.put("name", account.get("name"));
@@ -890,6 +942,89 @@ public class RbiReserveReturnApiResource {
return out;
}
+ /**
+ * Fills in a line's figure at the OTHER of the return's two dates, so every row can be read
+ * across both columns rather than only at the one date its own figure belongs to.
+ *
+ *
+ * The return is built from figures struck at two different dates - the base at its lagged date,
+ * the holdings at the reporting date - and a reader comparing them has no way to tell a real
+ * movement from the two dates simply being different. Reading each line at both dates makes the
+ * movement visible: the DTL line then shows what the liabilities were on the base date AND what
+ * they are now, and the cash line the same.
+ *
+ *
+ * The line's own {@code amount} is left exactly as it was. That is the figure the return is
+ * computed from, and nothing here may move it.
+ */
+ private Map withComparison(final Map row, final String configKey,
+ final LocalDate baseDate, final LocalDate reportingDate, final Long officeId) {
+ if (configKey == null) {
+ return row;
+ }
+ final String ownDate = (String) row.get("asOnDate");
+ final boolean ownIsBase = baseDate.toString().equals(ownDate);
+ final LocalDate otherDate = ownIsBase ? reportingDate : baseDate;
+
+ final RoleFigure other = figureFor(configKey, otherDate, officeId);
+ row.put("comparisonDate", otherDate.toString());
+ row.put("comparisonAmount", other.configured ? other.amount : null);
+
+ // Stated as base-then-current whichever date the line's own figure belongs to, so a column
+ // means the same thing on every row of the return.
+ final BigDecimal own = (BigDecimal) row.get("amount");
+ row.put("baseAmount", ownIsBase ? own : other.amount);
+ row.put("currentAmount", ownIsBase ? other.amount : own);
+ if (own != null) {
+ final BigDecimal from = ownIsBase ? own : other.amount;
+ final BigDecimal to = ownIsBase ? other.amount : own;
+ row.put("movement", to.subtract(from));
+ }
+
+ // The accounts behind the line get the same treatment as the line itself. Without this the
+ // breakdown states each account at one date and leaves the other column blank, so a reader
+ // who can see a head has moved cannot see WHICH account moved - which is the one question
+ // the breakdown exists to answer.
+ //
+ // Matched on the mapping row, so a savings balance split into a demand slice and a time
+ // slice keeps the two apart instead of giving both the same figure.
+ final Map> otherAccounts = new LinkedHashMap<>();
+ for (final Map account : other.accounts) {
+ final Object key = account.get("mappingId");
+ if (key != null) {
+ otherAccounts.put(((Number) key).longValue(), account);
+ }
+ }
+ @SuppressWarnings("unchecked")
+ final List> ownAccounts = (List>) row.get("accounts");
+ if (ownAccounts != null) {
+ for (final Map account : ownAccounts) {
+ final Object key = account.get("mappingId");
+ final Map match = key == null ? null
+ : otherAccounts.get(((Number) key).longValue());
+ // An account mapped since the other date has no row there. Left null rather than
+ // shown as zero: it did not hold nothing, it did not exist on the return.
+ final BigDecimal otherBalance = match == null ? null : (BigDecimal) match.get("balance");
+ final BigDecimal ownBalance = (BigDecimal) account.get("balance");
+ account.put("baseBalance", ownIsBase ? ownBalance : otherBalance);
+ account.put("currentBalance", ownIsBase ? otherBalance : ownBalance);
+ // The account's own unweighted balance at the other date too, so a weighted row
+ // still reconciles against the ledger in both columns.
+ final BigDecimal otherAccountBalance = match == null ? null
+ : (BigDecimal) match.get("accountBalance");
+ final BigDecimal ownAccountBalance = (BigDecimal) account.get("accountBalance");
+ account.put("baseAccountBalance", ownIsBase ? ownAccountBalance : otherAccountBalance);
+ account.put("currentAccountBalance", ownIsBase ? otherAccountBalance : ownAccountBalance);
+ if (ownBalance != null && otherBalance != null) {
+ final BigDecimal from = ownIsBase ? ownBalance : otherBalance;
+ final BigDecimal to = ownIsBase ? otherBalance : ownBalance;
+ account.put("movement", to.subtract(from));
+ }
+ }
+ }
+ return row;
+ }
+
/**
* Human-readable reasons the return could not be computed, for the screen to show verbatim.
*
@@ -935,4 +1070,1331 @@ public class RbiReserveReturnApiResource {
private static LocalDate parseOrBusinessDate(final String param) {
return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param);
}
+
+ /**
+ * Refuses a date the bank has not reached yet.
+ *
+ * A balance is summed as {@code entry_date <= date}, so a future date does not fail and does not
+ * come back empty - it returns TODAY'S balance under tomorrow's heading. The return then looks
+ * complete and is wrong in the one way nothing on its face can show: a fortnight that has not
+ * closed reported as though it had, with figures that will still move before it does.
+ *
+ * Checked against the business date rather than the wall clock, because that is the date every
+ * figure on the return is read at.
+ */
+ private static LocalDate requirePastOrPresent(final LocalDate date, final String what) {
+ final LocalDate today = DateUtils.getBusinessLocalDate();
+ if (date.isAfter(today)) {
+ throw new PlatformDataIntegrityException("error.msg.rbireservereturn.future.date",
+ what + " (" + date + ") is in the future; the bank's business date is " + today
+ + ". A period that has not closed cannot be reported.",
+ what, date.toString(), today.toString());
+ }
+ return date;
+ }
+
+ /** The earlier of two dates. */
+ private static LocalDate minimum(final LocalDate a, final LocalDate b) {
+ return a.isAfter(b) ? b : a;
+ }
+
+ // ══════════════ Statutory Form 1 ══════════════
+
+ /**
+ * Form 1 as a co-operative bank actually files it - Banking Regulation (Co-operative Societies)
+ * Rules 1966, Form 1 under Rule 5, sections 18(1) and 24(3).
+ *
+ *
+ * This is a different statement from the {@code form1} endpoint above, not a reformatting of
+ * it. Three differences matter:
+ *
+ *
+ * Three columns. The form reports two alternate Fridays side by side with the
+ * reporting date, so a reviewer sees the position move across the fortnight rather than a
+ * single snapshot.
+ * The base date. Both ratios are charged on the liabilities of the last Friday of
+ * the SECOND PRECEDING fortnight, not on the column's own liabilities. That lag is what gives a
+ * bank time to arrange its position, and reading the rate off the current column would report a
+ * requirement the bank never had.
+ * One form, both ratios. Part B settles section 18 (cash reserve) and Part C
+ * section 24 (liquid assets) on the same liabilities, so they are computed together.
+ *
+ *
+ *
+ * Every figure is read from the heads an administrator mapped; nothing is hardcoded to a GL
+ * code. A head left unmapped reports as nil, because on this form a nil line is a real answer -
+ * a bank with no balances at the State Co-operative Bank files that line empty.
+ */
+ @GET
+ @Path("form1-statutory")
+ public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam,
+ @QueryParam("firstColumnDate") final String firstColumnDateParam,
+ @QueryParam("secondColumnDate") final String secondColumnDateParam,
+ @QueryParam("officeId") final Long officeId) {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
+
+ final LocalDate requestedDate = parseOrBusinessDate(reportingDateParam);
+
+ // The two alternate Fridays the form reports, and the reporting date itself. The filed form
+ // shows the earlier Friday first, so the columns read left to right in time order.
+ // Fortnights run 1st-15th and 16th-month end, and each closes on its last day. Those closes
+ // are what the form's columns report, not alternate Fridays: a filed Form 1 for August
+ // heads its columns 15/08 and 31/08, which are the fortnight ends however the weekdays
+ // fall. The reporting date itself is the third column.
+ //
+ // Both closes can be given outright. A bank filing August wants 15/08 and 31/08 on the face
+ // of the form whatever today is, and deriving them from one date cannot produce a pair that
+ // spans a month boundary or skips a fortnight - which is exactly what a late or corrected
+ // filing needs. Given explicitly they are used as given; otherwise they are derived as
+ // before.
+ //
+ // Both are refused if they have not happened yet: the figures under a future close are
+ // today's, and a fortnight still running would be filed as though it had closed.
+ final LocalDate currentClose = secondColumnDateParam == null || secondColumnDateParam.isBlank()
+ // Derived, the close can legitimately fall ahead of today - it is the fortnight the
+ // requested date sits inside. Pulled back to the business date so the form reports
+ // the position as far as it exists rather than refusing its own default.
+ ? minimum(fortnightCloseOnOrAfter(requestedDate), DateUtils.getBusinessLocalDate())
+ : requirePastOrPresent(LocalDate.parse(secondColumnDateParam), "The second column's date");
+ final LocalDate previousClose = firstColumnDateParam == null || firstColumnDateParam.isBlank()
+ ? fortnightCloseBefore(currentClose)
+ : requirePastOrPresent(LocalDate.parse(firstColumnDateParam), "The first column's date");
+
+ // The form is made up to the later of its two closes. Taking the reporting date separately
+ // let a caller pass one that belonged to a different fortnight than the columns, and the
+ // rate in force was then looked up on that stray date - so a form could be headed one
+ // period and computed at another period's rate, with nothing on it to show the mismatch.
+ final LocalDate reportingDate = currentClose;
+ // The form reads left to right as the period advances. Reversed, every movement across it
+ // carries the wrong sign while the form still looks properly filled in.
+ if (!previousClose.isBefore(currentClose)) {
+ throw new PlatformDataIntegrityException("error.msg.rbireservereturn.columns.out.of.order",
+ "The first column (" + previousClose + ") must fall before the second (" + currentClose + ").",
+ previousClose.toString(), currentClose.toString());
+ }
+
+ // The third column is the base the current fortnight's ratios are charged on - the last
+ // Friday of the second preceding fortnight - not the reporting date.
+ //
+ // It was the reporting date. Since the return is now made up to the second column, that
+ // made the third column a copy of the second: two identically dated columns carrying
+ // identical figures, which reads as a filled form and says nothing. The base belongs on the
+ // face of the form because every required amount below is computed from it, and a reviewer
+ // cannot check a requirement whose base is not stated.
+ final LocalDate currentBase = formOneBaseDate(currentClose);
+ final List columnDates = List.of(previousClose, currentClose, currentBase);
+
+ final Map response = new LinkedHashMap<>();
+ response.put("formCode", "FORM_1_STATUTORY");
+ response.put("formName", "Form 1 (Rule 5) — Sections 18(1) and 24(3)");
+ response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966");
+ response.put("reportingDate", reportingDate.toString());
+ response.put("columns", columnDates.stream().map(LocalDate::toString).toList());
+
+ final Rate crr = rateInForce("CRR", reportingDate);
+ final Rate slr = rateInForce("SLR", reportingDate);
+ response.put("crrPercentage", crr == null ? null : crr.percentage);
+ response.put("slrPercentage", slr == null ? null : slr.percentage);
+
+ final List> parts = new ArrayList<>();
+ parts.add(formOnePartA(columnDates, officeId, crr, slr));
+ response.put("partA", parts.get(0));
+ response.put("partB", formOnePartB(columnDates, officeId, crr));
+ response.put("partC", formOnePartC(columnDates, officeId, slr));
+
+ final List unmet = new ArrayList<>();
+ if (crr == null) {
+ unmet.add("No CRR rate is on record effective on or before the reporting date.");
+ }
+ if (slr == null) {
+ unmet.add("No SLR rate is on record effective on or before the reporting date.");
+ }
+
+ // A nil base makes both required amounts nil, and the form then reads as a bank with no
+ // reserve requirement at all - which is what a bank in breach would also look like. The
+ // base is dated well before the columns, so a ledger that only starts part-way through can
+ // have real liabilities on its face and nothing behind the ratio, and nothing on the form
+ // says why. Named here with the date, so the nil is read as "no liabilities on that day"
+ // rather than as "nothing required".
+ final BigDecimal baseLiabilities = formOneNetLiabilities(currentBase, officeId);
+ if (baseLiabilities.compareTo(BigDecimal.ZERO) == 0) {
+ unmet.add("Both ratios are charged on the net liabilities of " + currentBase
+ + " — the last Friday of the second preceding fortnight — and the ledger holds "
+ + "none on that date. The required amounts are therefore nil. They are nil "
+ + "because the base is nil, not because nothing is required.");
+ }
+
+ response.put("unmetRequirements", unmet);
+ return gson.toJson(response);
+ }
+
+ /** Part A: the liabilities and assets the two ratios are worked out from. */
+ private Map formOnePartA(final List dates, final Long officeId, final Rate crr,
+ final Rate slr) {
+ final List> lines = new ArrayList<>();
+
+ lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM"));
+ lines.add(formOneHeading("(a) Demand Liabilities"));
+ final List iai = formOneLine(lines, "(i) Current account balances of SBI, its subsidiaries and the corresponding new banks",
+ "FORM1_I_A_I_SBI_CURRENT", dates, officeId, 2);
+ final List iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system",
+ "FORM1_I_A_II_OTHER_DEMAND_BS", dates, officeId, 2);
+ final List ib = formOneLine(lines, "(b) Time liabilities to the banking system", "FORM1_I_B_TIME_BS",
+ dates, officeId, 1);
+ final List totalI = sumOf(iai, iaii, ib);
+ lines.add(formOneTotal("TOTAL OF I", totalI));
+
+ lines.add(formOneHeading("II. LIABILITIES IN INDIA TO OTHERS"));
+ // Demand and time come from the one DTL head, split by the class each mapped account
+ // carries - the same split the fortnightly return reports.
+ final List iiA = new ArrayList<>();
+ final List iiB = new ArrayList<>();
+ for (final LocalDate date : dates) {
+ final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", date, officeId);
+ iiA.add(dtl.demandAmount);
+ iiB.add(dtl.timeAmount);
+ }
+ lines.add(formOneRow("(A) Demand liabilities", "DEMAND_TIME_LIABILITIES", iiA, 1));
+ lines.add(formOneRow("(B) Time liabilities", "DEMAND_TIME_LIABILITIES", iiB, 1));
+ final List totalII = sumOf(iiA, iiB);
+ lines.add(formOneTotal("TOTAL OF II", totalII));
+
+ lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM"));
+ final List iiiA = formOneLine(lines, "(A) Current account balances with SBI, its subsidiaries and the corresponding new banks",
+ "FORM1_III_A_SBI_CURRENT", dates, officeId, 1);
+ lines.add(formOneHeading("(B) Other assets with the banking system"));
+ final List iiiBi = formOneLine(lines, "(i) Balances in all accounts other than III(A)",
+ "FORM1_III_B_I_OTHER_BALANCES", dates, officeId, 2);
+ final List iiiBii = formOneLine(lines, "(ii) Money at call and short notice",
+ "FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2);
+ final List iiiBiv = formOneLine(lines, "(iv) Advances and any other assets",
+ "FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2);
+ final List totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv);
+ lines.add(formOneTotal("TOTAL OF III", totalIII));
+
+ // IV: the banking system nets out, and what is left is added to the liabilities to others.
+ // Floored at zero per column - the form's own wording, "(I - III) + II if minus figure",
+ // says the netting cannot push the base below the liabilities to others.
+ final List iv = new ArrayList<>();
+ for (int i = 0; i < dates.size(); i++) {
+ iv.add(totalI.get(i).subtract(totalIII.get(i)).max(BigDecimal.ZERO).add(totalII.get(i)));
+ }
+ lines.add(formOneTotal("IV. TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24", iv));
+
+ final List v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0);
+
+ lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH"));
+ final List viA = formOneLine(lines, "(a) Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", dates,
+ officeId, 1);
+ final List viB = formOneLine(lines, "(b) State Co-operative Bank of the state concerned",
+ "FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1);
+ final List viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned",
+ "FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1);
+ final List totalVI = sumOf(viA, viB, viC);
+ lines.add(formOneTotal("TOTAL OF VI", totalVI));
+
+ lines.add(formOneHeading("VII. BALANCES OF ALL OTHER TYPES WITH"));
+ final List viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned",
+ "FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1);
+ final List viiB = formOneLine(lines, "(b) District Central Co-operative Bank",
+ "FORM1_VII_B_DCCB_OTHER", dates, officeId, 1);
+ final List viiC = formOneLine(lines, "(c) Investment in Government securities",
+ "FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1);
+ final List totalVII = sumOf(viiA, viiB, viiC);
+ lines.add(formOneTotal("TOTAL OF VII", totalVII));
+
+ // VIII: only the excess counts. Where the banking system's claim on this bank exceeds the
+ // bank's own balance there is nothing to carry into the cash reserve, so it floors at zero.
+ final List viii = new ArrayList<>();
+ for (int i = 0; i < dates.size(); i++) {
+ viii.add(iiiA.get(i).subtract(iai.get(i)).max(BigDecimal.ZERO));
+ }
+ lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii));
+
+ // The liabilities the ratios are charged on: the last Friday of the second preceding
+ // fortnight, read per column rather than once, so each column states its own base.
+ final List baseDateDtl = new ArrayList<>();
+ final List baseDates = new ArrayList<>();
+ for (final LocalDate date : dates) {
+ final LocalDate base = formOneBaseDate(date);
+ baseDates.add(base.toString());
+ baseDateDtl.add(formOneNetLiabilities(base, officeId));
+ }
+ final Map baseRow = formOneTotal(
+ "DEMAND AND TIME LIABILITIES OF THE BASE DATE — last Friday of the second preceding fortnight",
+ baseDateDtl);
+ baseRow.put("baseDates", baseDates);
+ lines.add(baseRow);
+
+ final Map part = new LinkedHashMap<>();
+ part.put("title", "PART A");
+ part.put("lines", lines);
+ part.put("baseDates", baseDates);
+ part.put("baseDateLiabilities", baseDateDtl);
+ return part;
+ }
+
+ /** Part B: compliance with section 18 — the cash reserve. */
+ private Map formOnePartB(final List dates, final Long officeId, final Rate crr) {
+ final List> lines = new ArrayList<>();
+ final List required = new ArrayList<>();
+ final List maintained = new ArrayList<>();
+
+ for (final LocalDate date : dates) {
+ final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId);
+ required.add(crr == null ? BigDecimal.ZERO
+ : base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP));
+ // X = V + VI + VIII, the form's own definition.
+ final BigDecimal cash = figureFor("CASH_IN_HAND", date, officeId).amount;
+ final BigDecimal currentAccounts = figureFor("FORM1_VI_A_RBI_CURRENT", date, officeId).amount
+ .add(figureFor("FORM1_VI_B_STATE_COOP_CURRENT", date, officeId).amount)
+ .add(figureFor("FORM1_VI_C_DCCB_CURRENT", date, officeId).amount);
+ final BigDecimal netCurrent = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount
+ .subtract(figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount).max(BigDecimal.ZERO);
+ maintained.add(cash.add(currentAccounts).add(netCurrent));
+ }
+
+ lines.add(formOneTotal("IX. " + (crr == null ? "CRR" : crr.percentage + "%")
+ + " of IV as on the last Friday of the second preceding fortnight", required));
+ lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained));
+
+ final Map part = new LinkedHashMap<>();
+ part.put("title", "PART B — Compliance with Section 18");
+ part.put("lines", lines);
+ part.put("required", required);
+ part.put("maintained", maintained);
+ part.put("surplus", differenceOf(maintained, required));
+ return part;
+ }
+
+ /** Part C: compliance with section 24 — the liquid assets. */
+ private Map formOnePartC(final List dates, final Long officeId, final Rate slr) {
+ final List> lines = new ArrayList<>();
+ final List required = new ArrayList<>();
+ final List cashAndOther = new ArrayList<>();
+ final List gold = new ArrayList<>();
+ final List securities = new ArrayList<>();
+
+ for (final LocalDate date : dates) {
+ final BigDecimal base = formOneNetLiabilities(formOneBaseDate(date), officeId);
+ final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
+ : base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
+ required.add(slrRequired);
+
+ // XII(a) is the cash reserve held OVER the section 18 requirement, plus the other
+ // balances of part VII. Only the excess counts: the part of the cash reserve that meets
+ // section 18 is already committed and cannot also meet section 24.
+ final BigDecimal crrRequired = formOnePartBRequired(date, officeId);
+ final BigDecimal crrMaintained = formOnePartBMaintained(date, officeId);
+ final BigDecimal excessCash = crrMaintained.subtract(crrRequired).max(BigDecimal.ZERO);
+ final BigDecimal otherBalances = figureFor("FORM1_VII_A_STATE_COOP_OTHER", date, officeId).amount
+ .add(figureFor("FORM1_VII_B_DCCB_OTHER", date, officeId).amount)
+ .add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount);
+ cashAndOther.add(excessCash.add(otherBalances));
+ gold.add(figureFor("FORMA_SLR_GOLD", date, officeId).amount);
+ securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount);
+ }
+
+ lines.add(formOneTotal("XI. " + (slr == null ? "SLR" : slr.percentage + "%")
+ + " of IV as on the last Friday of the second preceding fortnight", required));
+ lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED"));
+ lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1));
+ lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1));
+ lines.add(formOneRow("(c) Unencumbered approved securities", "SLR_INVESTMENTS", securities, 1));
+ final List totalXII = sumOf(cashAndOther, gold, securities);
+ lines.add(formOneTotal("TOTAL OF XII", totalXII));
+
+ final Map part = new LinkedHashMap<>();
+ part.put("title", "PART C — Compliance with Section 24");
+ part.put("lines", lines);
+ part.put("required", required);
+ part.put("maintained", totalXII);
+ part.put("surplus", differenceOf(totalXII, required));
+ return part;
+ }
+
+ private BigDecimal formOnePartBRequired(final LocalDate date, final Long officeId) {
+ final Rate crr = rateInForce("CRR", date);
+ if (crr == null) {
+ return BigDecimal.ZERO;
+ }
+ return formOneNetLiabilities(formOneBaseDate(date), officeId).multiply(crr.percentage).divide(HUNDRED, 2,
+ RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal formOnePartBMaintained(final LocalDate date, final Long officeId) {
+ final BigDecimal cash = figureFor("CASH_IN_HAND", date, officeId).amount;
+ final BigDecimal currentAccounts = figureFor("FORM1_VI_A_RBI_CURRENT", date, officeId).amount
+ .add(figureFor("FORM1_VI_B_STATE_COOP_CURRENT", date, officeId).amount)
+ .add(figureFor("FORM1_VI_C_DCCB_CURRENT", date, officeId).amount);
+ final BigDecimal netCurrent = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount
+ .subtract(figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount).max(BigDecimal.ZERO);
+ return cash.add(currentAccounts).add(netCurrent);
+ }
+
+ /**
+ * The date both ratios are charged on: the last Friday of the SECOND PRECEDING fortnight.
+ *
+ *
+ * Counted in fortnights, not in weeks. For a column closing 15 August the fortnight it belongs
+ * to is 1-15 August, the first preceding one is 16-31 July, and the second is 1-15 July - so
+ * the base falls in the first half of July, roughly six weeks back rather than the four a
+ * straight subtraction would give. Reading the rate off a date a fortnight too late would
+ * report a requirement the bank never had time to meet, which is the whole point of the lag.
+ *
+ *
+ * Within that fortnight the form takes its last Friday, shifted off a holiday the same way
+ * every other reporting date is.
+ */
+ private LocalDate formOneBaseDate(final LocalDate columnDate) {
+ final LocalDate ownClose = fortnightCloseOnOrAfter(columnDate);
+ final LocalDate secondPrecedingClose = fortnightCloseBefore(fortnightCloseBefore(ownClose));
+ return precedingWorkingDay(reportingFridayOnOrBefore(secondPrecedingClose));
+ }
+
+ /**
+ * The close of the fortnight a date falls in: the 15th for the first half of a month, the last
+ * day of the month for the second.
+ */
+ private static LocalDate fortnightCloseOnOrAfter(final LocalDate date) {
+ return date.getDayOfMonth() <= 15 ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth());
+ }
+
+ /** The close of the fortnight before the one ending on the given close. */
+ private static LocalDate fortnightCloseBefore(final LocalDate close) {
+ return close.getDayOfMonth() <= 15
+ // First half of a month: the fortnight before it ends with the previous month.
+ ? close.minusMonths(1).with(TemporalAdjusters.lastDayOfMonth())
+ // Second half: the fortnight before it ends on the 15th of the same month.
+ : close.withDayOfMonth(15);
+ }
+
+ /** Item IV computed for one date: (I − III floored at zero) + II. */
+ private BigDecimal formOneNetLiabilities(final LocalDate date, final Long officeId) {
+ final BigDecimal toBankingSystem = figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount
+ .add(figureFor("FORM1_I_A_II_OTHER_DEMAND_BS", date, officeId).amount)
+ .add(figureFor("FORM1_I_B_TIME_BS", date, officeId).amount);
+ final BigDecimal withBankingSystem = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount
+ .add(figureFor("FORM1_III_B_I_OTHER_BALANCES", date, officeId).amount)
+ .add(figureFor("FORM1_III_B_II_MONEY_AT_CALL_BS", date, officeId).amount)
+ .add(figureFor("FORM1_III_B_IV_ADVANCES_BS", date, officeId).amount);
+ final BigDecimal toOthers = figureFor("DEMAND_TIME_LIABILITIES", date, officeId).amount;
+ return toBankingSystem.subtract(withBankingSystem).max(BigDecimal.ZERO).add(toOthers);
+ }
+
+ // ── Form 1 row builders ──
+
+ private static Map formOneHeading(final String label) {
+ final Map row = new LinkedHashMap<>();
+ row.put("label", label);
+ row.put("heading", true);
+ return row;
+ }
+
+ private static Map formOneRow(final String label, final String configKey,
+ final List values, final int indent) {
+ final Map row = new LinkedHashMap<>();
+ row.put("label", label);
+ row.put("configKey", configKey);
+ row.put("indent", indent);
+ row.put("values", values);
+ return row;
+ }
+
+ private static Map formOneTotal(final String label, final List values) {
+ final Map row = formOneRow(label, null, values, 0);
+ row.put("total", true);
+ return row;
+ }
+
+ /** Reads one head across every column and appends its row. */
+ private List formOneLine(final List> lines, final String label,
+ final String configKey, final List dates, final Long officeId, final int indent) {
+ final List values = new ArrayList<>();
+ boolean configured = false;
+ for (final LocalDate date : dates) {
+ final RoleFigure figure = figureFor(configKey, date, officeId);
+ configured |= figure.configured;
+ values.add(figure.amount);
+ }
+ final Map row = formOneRow(label, configKey, values, indent);
+ row.put("configured", configured);
+ lines.add(row);
+ return values;
+ }
+
+ @SafeVarargs
+ private static List sumOf(final List... columns) {
+ final List out = new ArrayList<>();
+ final int size = columns.length == 0 ? 0 : columns[0].size();
+ for (int i = 0; i < size; i++) {
+ BigDecimal total = BigDecimal.ZERO;
+ for (final List column : columns) {
+ total = total.add(column.get(i));
+ }
+ out.add(total);
+ }
+ return out;
+ }
+
+ private static List differenceOf(final List a, final List b) {
+ final List out = new ArrayList<>();
+ for (int i = 0; i < a.size(); i++) {
+ out.add(a.get(i).subtract(b.get(i)));
+ }
+ return out;
+ }
+
+ /**
+ * Form 9 in the shape it is filed - the statutory statement of compliance with section 24.
+ *
+ *
+ * The same Part A and Part C the statutory Form 1 carries, on this return's own dates. Form 1
+ * reports two fortnight closes because it is filed fortnightly; Form 9 is filed monthly, so it
+ * reports the month it covers and the month before, and charges the ratio on the last Friday of
+ * the second preceding fortnight exactly as Form 1 does.
+ *
+ *
+ * Part B is left out. That part settles section 18, which is the cash reserve - Form 1's
+ * subject, not this one's. Printing it here would put a CRR compliance statement on an SLR
+ * return, where a reader has no reason to expect one.
+ *
+ * @param reportingDate any date in the month; the return reports that month's end.
+ * @param officeId Optional - scopes every figure to one office.
+ */
+ @GET
+ @Path("form9-statutory")
+ public String form9Statutory(@QueryParam("reportingDate") final String reportingDateParam,
+ @QueryParam("officeId") final Long officeId) {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
+
+ final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam),
+ "The reporting date");
+ final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth());
+ final LocalDate today = DateUtils.getBusinessLocalDate();
+ // A month end that has not arrived would report today's balances under its heading - a month
+ // still running filed as a closed one.
+ final LocalDate currentClose = minimum(nominalMonthEnd, today);
+ final boolean monthComplete = !nominalMonthEnd.isAfter(today);
+ final LocalDate previousClose = requested.withDayOfMonth(1).minusDays(1);
+ final LocalDate base = formOneBaseDate(currentClose);
+
+ final List columnDates = List.of(previousClose, currentClose, base);
+
+ final Map response = new LinkedHashMap<>();
+ response.put("formCode", "FORM_9_STATUTORY");
+ response.put("formName", "Form 9 - Statement of Compliance with Section 24 (SLR)");
+ response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966");
+ response.put("frequency", "Monthly");
+ response.put("reportingDate", currentClose.toString());
+ response.put("monthEnd", nominalMonthEnd.toString());
+ response.put("monthComplete", monthComplete);
+ response.put("columns", columnDates.stream().map(LocalDate::toString).toList());
+
+ final Rate crr = rateInForce("CRR", currentClose);
+ final Rate slr = rateInForce("SLR", currentClose);
+ response.put("crrPercentage", crr == null ? null : crr.percentage);
+ response.put("slrPercentage", slr == null ? null : slr.percentage);
+
+ // Part A establishes the liabilities both ratios are charged on, so it is needed here even
+ // though only section 24 is settled below: without it the required amount at XI is a figure
+ // with nothing behind it.
+ response.put("partA", formOnePartA(columnDates, officeId, crr, slr));
+ response.put("partC", formOnePartC(columnDates, officeId, slr));
+
+ final List unmet = new ArrayList<>();
+ if (slr == null) {
+ unmet.add("No SLR rate is on record effective on or before the reporting date.");
+ }
+ if (crr == null) {
+ // Not an aside: XII(a) counts only the cash reserve held OVER the section 18
+ // requirement, so without a CRR rate that line takes the whole reserve and overstates
+ // what is available to meet section 24.
+ unmet.add("No CRR rate is on record. The cash counted at XII(a) is the reserve held over "
+ + "the section 18 requirement, so without a CRR rate that line is overstated.");
+ }
+ if (!monthComplete) {
+ unmet.add("The month ending " + nominalMonthEnd + " has not closed. This is the position "
+ + "as at " + currentClose + ", not the month-end position the return reports.");
+ }
+ // As on Form 1: a nil base makes the requirement nil, and the form then reads the same as a
+ // bank with nothing to maintain. Said with the date so the nil can be read for what it is.
+ if (formOneNetLiabilities(base, officeId).compareTo(BigDecimal.ZERO) == 0) {
+ unmet.add("The ratio is charged on the net liabilities of " + base + " — the last Friday "
+ + "of the second preceding fortnight — and the ledger holds none on that date. "
+ + "The required amount is therefore nil, because the base is nil.");
+ }
+ response.put("unmetRequirements", unmet);
+ return gson.toJson(response);
+ }
+
+ // ══════════════ Form IX - Return on Assets and Liabilities (R408) ══════════════
+
+ /** Rupees to rupees-thousands. Form IX reports in thousands; Form 1 reports in rupees. */
+ private static final BigDecimal THOUSAND = BigDecimal.valueOf(1000);
+
+ /** role_group for Form IX heads, so they sit apart from Form 1's on the configuration screen. */
+ private static final String GROUP_FORM9 = "FORM9";
+
+ private static final String USAGE_ASSET = "ASSET";
+
+ /**
+ * Form IX - the Return on Assets and Liabilities a co-operative bank files monthly, return code
+ * R408.
+ *
+ *
+ * A different return from {@code form9} above despite the name. That one reports a ratio: the
+ * liquid assets held against the liabilities, and whether the bank complied. This one reports
+ * the bank's whole balance sheet - every class of liability and every class of asset - and its
+ * two sides must agree.
+ *
+ *
+ * Three things about it differ from every other return here:
+ *
+ *
+ * Thousands. Every figure is in rupees thousands. The conversion happens once, on a
+ * finished total, never per account - see {@code thousands}.
+ * Unmapped heads still print. Elsewhere a head with nothing mapped is left off. Here
+ * a nil line is a real answer - a bank with no borrowings from the EXIM Bank files that line
+ * empty - so every head appears, with whether it was configured stated beside it.
+ * Both sides are totalled independently and their difference reported. The filed
+ * return this was built from balanced its sides by subtracting the difference from the side
+ * that was already short, and printed "DIFF ADJUSTED" rather than saying the balance did not
+ * hold. A figure is never adjusted here to make the form balance.
+ *
+ *
+ * @param reportingDate any date in the month; the return reports that month's end.
+ * @param officeId Optional - scopes every figure to one office.
+ */
+ @GET
+ @Path("form9-return")
+ public String form9Return(@QueryParam("reportingDate") final String reportingDateParam,
+ @QueryParam("officeId") final Long officeId) {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
+
+ final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam),
+ "The reporting date");
+ final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth());
+ final LocalDate today = DateUtils.getBusinessLocalDate();
+ // Run inside the current month, the month end has not arrived. Reading balances at it would
+ // return today's figures under the month-end heading - a month still running reported as a
+ // closed one, with nothing on the return to say so.
+ final LocalDate asOn = minimum(nominalMonthEnd, today);
+ final boolean monthComplete = !nominalMonthEnd.isAfter(today);
+
+ final Map response = new LinkedHashMap<>();
+ response.put("formCode", "FORM_9_RETURN");
+ response.put("formName", "Form IX - Return on Assets and Liabilities");
+ response.put("returnCode", "R408");
+ response.put("statutoryReference", "The Banking Regulation Act - Form IX");
+ response.put("frequency", "Monthly");
+ response.put("unit", "Rs. in Thousands");
+ response.put("reportingDate", asOn.toString());
+ response.put("monthEnd", nominalMonthEnd.toString());
+ response.put("monthComplete", monthComplete);
+ response.put("note", monthComplete
+ ? "Every figure is a closing balance as at " + asOn + ", in rupees thousands."
+ : "The month ending " + nominalMonthEnd + " has not closed. Figures are the position "
+ + "as at " + asOn + " and will move before the month ends.");
+
+ // Every Form IX head, mapped or not, in the order the form prints them.
+ final List> roles = jdbcTemplate.queryForList("""
+ SELECT config_key, label, role_usage
+ FROM m_rbi_reserve_role
+ WHERE role_group = ?
+ ORDER BY position, label
+ """, GROUP_FORM9);
+
+ final Map> byKey = new LinkedHashMap<>();
+ BigDecimal liabilityTotal = BigDecimal.ZERO;
+ BigDecimal assetTotal = BigDecimal.ZERO;
+ final List> liabilityLines = new ArrayList<>();
+ final List> assetLines = new ArrayList<>();
+ final List> memoLines = new ArrayList<>();
+ int unmappedCount = 0;
+
+ for (final Map role : roles) {
+ final String key = (String) role.get("config_key");
+ final String usage = (String) role.get("role_usage");
+ final RoleFigure figure = figureFor(key, asOn, officeId);
+
+ final Map row = new LinkedHashMap<>();
+ row.put("configKey", key);
+ row.put("label", role.get("label"));
+ row.put("usage", usage);
+ row.put("configured", figure.configured);
+ // Rupees as well as thousands. The rupee figure is what the ledger actually holds, and a
+ // reviewer checking a line against the trial balance needs it: a thousands figure alone
+ // cannot be reconciled, because the rounding already happened.
+ row.put("amountInRupees", figure.configured ? figure.amount : null);
+ row.put("amount", figure.configured ? thousands(figure.amount) : null);
+ row.put("accounts", accountsInThousands(figure));
+ byKey.put(key, row);
+
+ if (!figure.configured) {
+ unmappedCount++;
+ }
+
+ if (USAGE_LIABILITY.equals(usage)) {
+ liabilityLines.add(row);
+ liabilityTotal = liabilityTotal.add(figure.amount);
+ } else if (USAGE_ASSET.equals(usage)) {
+ assetLines.add(row);
+ assetTotal = assetTotal.add(figure.amount);
+ } else {
+ memoLines.add(row);
+ }
+ }
+
+ // A loss belongs on the asset side at item 12, not on the liability side as a negative
+ // item 9. The form carries two separate lines because the year's result goes to one of them
+ // or the other, never to both: a bank that made a loss files item 9 empty and item 12 with
+ // the figure. Reported as a negative liability instead it pulls the liability total DOWN by
+ // the loss, when a loss makes the two sides agree by being added to the assets - so the
+ // return came out short by twice the loss and said the balance did not hold.
+ //
+ // Moved rather than left with a caveat, because this one has a right answer: the figure is
+ // correct, only its side is wrong, and the form itself says which side that is.
+ // ── The year's own result ──
+ //
+ // Income and expense accounts hold the year's trading, and no balance-sheet head maps them
+ // because they are not balance-sheet accounts. But their NET is - it is the profit or loss
+ // the balance sheet closes with, and without it the two sides cannot agree by exactly that
+ // amount.
+ //
+ // What the equity profit accounts hold is LAST year's result and what has been appropriated
+ // out of it - a year-end closing entry moved it there when the books were closed, and the
+ // dividend and reserve transfers drew it down. That is a balance-sheet figure in its own
+ // right and is already reported through the accounts mapped to this head.
+ //
+ // This year's trading has not been closed to anywhere, so it is added on top. Adding it to
+ // what the equity accounts hold is not double counting: the year-end entry that would move
+ // it there has not run, which is exactly why the two sides were out by this amount.
+ //
+ // The whole figure - brought forward plus this year - then goes to ONE of the two lines the
+ // form carries for it. Positive it is item 9 on the liability side, negative it is item 12
+ // on the asset side, and never a share of each. Two passes were tried, one adding the result
+ // and a second moving a negative across, and the second subtracted the whole balance from a
+ // total the first had already adjusted - so the return moved by the result twice.
+ final BigDecimal tradingResult = unclosedTradingResult(asOn, officeId);
+ final Map profitRow = byKey.get("FORM9_L9_BALANCE_OF_PROFIT");
+ final Map lossRow = byKey.get("FORM9_A12_BALANCE_OF_LOSS");
+ if (profitRow != null) {
+ final BigDecimal broughtForward = profitRow.get("amountInRupees") == null ? BigDecimal.ZERO
+ : (BigDecimal) profitRow.get("amountInRupees");
+ final BigDecimal result = broughtForward.add(tradingResult);
+
+ // Whatever the profit head contributed to the liability total in the loop above is taken
+ // back out, so the result can be placed once, on whichever side it belongs.
+ liabilityTotal = liabilityTotal.subtract(broughtForward);
+
+ // Two figures, each named: what the equity accounts hold, and what the income and
+ // expense accounts still hold because no year-end entry has closed them. A reviewer
+ // checking this line against the ledger needs both, since neither account alone shows it.
+ final String provenance = "Equity accounts mapped here: " + rupees(broughtForward)
+ + ". Income and expense accounts not yet closed to equity: " + rupees(tradingResult) + ".";
+
+ if (result.compareTo(BigDecimal.ZERO) < 0 && lossRow != null) {
+ final BigDecimal loss = result.negate();
+ assetTotal = assetTotal.add(loss);
+
+ // The accounts move WITH the figure, they are not copied to both lines. Item 9 now
+ // reports nil and has nothing to show; listing its accounts under a nil total said
+ // the bank held a profit made up of two balances that came to nothing. Worse, they
+ // were also printed under item 12 with their signs flipped, so the same account
+ // appeared twice with opposite figures and read as counted twice.
+ final Object accountsBehind = profitRow.get("accounts");
+ profitRow.put("amountInRupees", BigDecimal.ZERO);
+ profitRow.put("amount", BigDecimal.ZERO);
+ profitRow.put("accounts", List.of());
+ profitRow.put("note", "Nil — the bank stands in loss, so the figure is at item 12 on "
+ + "the asset side. " + provenance);
+
+ lossRow.put("configured", true);
+ lossRow.put("amountInRupees", loss);
+ lossRow.put("amount", thousands(loss));
+ // Exactly as the ledger holds them, signs and all. A loss IS a debit balance on an
+ // equity account, so showing it as one is the honest reading - and the unclosed
+ // trading is added as its own row because no account carries it. The three add to
+ // the loss, read as negatives, which is what a reviewer tracing the figure expects.
+ lossRow.put("accounts", withTradingRow(accountsBehind, tradingResult));
+ // The rows below carry the OPPOSITE sign to the head, because a loss reported as a
+ // positive figure on the asset side is made up of balances that are negative on the
+ // liability side. Saying so lets the screen head the column "as the ledger holds
+ // it", rather than leaving a reader to wonder how three negatives make a positive.
+ lossRow.put("accountsMirrorHead", true);
+ // Short, because the row above the balances already says they read the other way
+ // round. What belongs here is where the figure came from, not how to read it.
+ lossRow.put("note", "The bank stands in loss, so the figure is reported here rather "
+ + "than as a negative profit at item 9. " + provenance);
+ } else {
+ profitRow.put("configured", true);
+ profitRow.put("amountInRupees", result);
+ profitRow.put("amount", thousands(result));
+ profitRow.put("note", provenance);
+ liabilityTotal = liabilityTotal.add(result);
+ }
+ }
+
+ // ── Overdrawn holding accounts ──
+ //
+ // A liability that has been drawn past nil is not a liability at all: the bank has paid out
+ // more than it owed, and what is left is money owed BACK to it. On a balance sheet that is
+ // an asset, and the form has a line for exactly this - 11(i) Other Assets, tangible.
+ //
+ // Reported there rather than left as a negative liability. A negative on the liability side
+ // pulls the liability total down by the overdraw AND leaves the asset it really is off the
+ // asset side, so the balance breaks by twice the figure - the same way a loss did before it
+ // was moved to item 12. It is also unreadable: minus one thousand under "Other Demand
+ // Liabilities" looks like a small liability rather than a debt owed to the bank.
+ final Map otherAssets = byKey.get("FORM9_A11_I_TANGIBLE_ASSETS");
+ if (otherAssets != null) {
+ BigDecimal overdrawn = BigDecimal.ZERO;
+ final List overdrawnAccounts = new ArrayList<>();
+ for (final Map.Entry> keyed : byKey.entrySet()) {
+ // The profit head is exempt. Its accounts read negative because the bank is in
+ // loss, not because anyone drew them past nil, and the return has already carried
+ // that figure to item 12. Counting it here as well put the loss on the asset side
+ // twice - once at item 12 and once inside Other Assets - and inflated both sides of
+ // the return by it while still, by luck, agreeing.
+ if ("FORM9_L9_BALANCE_OF_PROFIT".equals(keyed.getKey())) {
+ continue;
+ }
+ final Map row = keyed.getValue();
+ if (!USAGE_LIABILITY.equals(row.get("usage"))) {
+ continue;
+ }
+ @SuppressWarnings("unchecked")
+ final List> accounts = (List>) row.get("accounts");
+ if (accounts == null) {
+ continue;
+ }
+ for (final Map account : accounts) {
+ final BigDecimal balance = (BigDecimal) account.get("balanceInRupees");
+ if (balance == null || balance.compareTo(BigDecimal.ZERO) >= 0) {
+ continue;
+ }
+ overdrawn = overdrawn.add(balance.negate());
+ overdrawnAccounts.add(account.get("glCode") + " " + account.get("name"));
+ }
+ }
+
+ if (overdrawn.compareTo(BigDecimal.ZERO) > 0) {
+ // Both totals move by the same amount and in the same direction: the liability side
+ // loses a negative, the asset side gains the positive it always was.
+ liabilityTotal = liabilityTotal.add(overdrawn);
+ assetTotal = assetTotal.add(overdrawn);
+
+ final BigDecimal existing = otherAssets.get("amountInRupees") == null ? BigDecimal.ZERO
+ : (BigDecimal) otherAssets.get("amountInRupees");
+ final BigDecimal total = existing.add(overdrawn);
+ otherAssets.put("configured", true);
+ otherAssets.put("amountInRupees", total);
+ otherAssets.put("amount", thousands(total));
+ otherAssets.put("note", "Includes " + rupees(overdrawn) + " rupees drawn out of holding accounts "
+ + "beyond what they held (" + String.join(", ", overdrawnAccounts) + "). A liability "
+ + "paid past nil is money owed back to the bank, which is an asset.");
+ }
+ }
+
+ response.put("liabilityLines", liabilityLines);
+ response.put("assetLines", assetLines);
+ response.put("memoLines", memoLines);
+
+ // ── Sub-totals the form states on its face ──
+ //
+ // Summed in RUPEES and converted once. Adding up figures that were each already rounded to
+ // thousands is what put the two sides of the filed return 2 thousand apart.
+ final Map totals = new LinkedHashMap<>();
+ totals.put("paidUpCapital", sumThousands(byKey, "FORM9_L1_A_CAPITAL_INDIVIDUALS",
+ "FORM9_L1_B_CAPITAL_COOP_SOCIETIES", "FORM9_L1_C_CAPITAL_STATE_GOVT", "FORM9_L1_D_CAPITAL_OTHERS"));
+ totals.put("reserveFund", sumThousands(byKey, "FORM9_L2_A_STATUTORY_RESERVE", "FORM9_L2_B_AGRI_CREDIT_FUND",
+ "FORM9_L2_C_DIVIDEND_EQUALISATION", "FORM9_L2_D_SPECIAL_BAD_DEBT", "FORM9_L2_E_BAD_DOUBTFUL_RESERVE",
+ "FORM9_L2_F_INVESTMENT_DEPRECIATION", "FORM9_L2_G_OTHER_FUNDS"));
+ totals.put("partnershipFund", sumThousands(byKey, "FORM9_L3_A_PARTNERSHIP_CCB", "FORM9_L3_B_PARTNERSHIP_PACS",
+ "FORM9_L3_C_PARTNERSHIP_OTHER"));
+
+ final BigDecimal demandRupees = sumRupees(byKey, "FORM9_L4_A1_CURRENT_CCB", "FORM9_L4_A2_CURRENT_PCB",
+ "FORM9_L4_A3_CURRENT_SOCIETIES", "FORM9_L4_A4_CURRENT_INDIVIDUALS", "FORM9_L4_A5_CURRENT_RESERVE_FUND",
+ "FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS", "FORM9_L4_B_SAVINGS_DEMAND_OTHER",
+ "FORM9_L4_C_BORROWINGS_DEMAND", "FORM9_L4_D_OTHER_DEMAND");
+ final BigDecimal timeRupees = sumRupees(byKey, "FORM9_L5_A1_FD_CCB", "FORM9_L5_A2_FD_PCB",
+ "FORM9_L5_A3_FD_SOCIETIES", "FORM9_L5_A4_FD_INDIVIDUALS", "FORM9_L5_A5_FD_RESERVE_FUND",
+ "FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS", "FORM9_L5_B_SAVINGS_TIME_OTHER", "FORM9_L5_C_BORROWINGS_TIME",
+ "FORM9_L5_D_CASH_CERT_RD", "FORM9_L5_E_OTHER_TIME");
+ final BigDecimal branchRupees = rupeesOf(byKey, "FORM9_L6_BRANCH_ADJUSTMENTS");
+
+ totals.put("demandLiabilities", thousands(demandRupees));
+ totals.put("timeLiabilities", thousands(timeRupees));
+ totals.put("branchAdjustments", thousands(branchRupees));
+ // Item 8 of the form: demand + time + branch adjustments, the figure CRR and SLR are charged
+ // on elsewhere. Converted from the rupee sum, not from the three thousands figures above.
+ totals.put("totalDemandAndTime", thousands(demandRupees.add(timeRupees).add(branchRupees)));
+ totals.put("balanceOfProfit", sumThousands(byKey, "FORM9_L9_BALANCE_OF_PROFIT"));
+ totals.put("totalLiabilities", thousands(liabilityTotal));
+
+ totals.put("cashInHand", sumThousands(byKey, "FORM9_A1_CASH_IN_HAND"));
+ totals.put("balanceWithBanksCurrent", sumThousands(byKey, "FORM9_A2_A_CUR_RBI", "FORM9_A2_B_CUR_SBI",
+ "FORM9_A2_C_CUR_BANKING_SYSTEM", "FORM9_A2_D_CUR_STATE_COOP", "FORM9_A2_E_CUR_DCCB",
+ "FORM9_A2_F_CUR_OTHER_COOP"));
+ totals.put("balanceWithBanksSavings", sumThousands(byKey, "FORM9_A2_SAV_BANKING_SYSTEM", "FORM9_A2_SAV_COOP"));
+ totals.put("moneyAtCall", sumThousands(byKey, "FORM9_A3_MONEY_AT_CALL"));
+ totals.put("investments", sumThousands(byKey, "FORM9_A4_A1_CENTRAL_GOVT_SEC", "FORM9_A4_A2_STATE_GOVT_SEC",
+ "FORM9_A4_B_LAND_MORTGAGE_DEBENTURES", "FORM9_A4_C_OTHER_TRUSTEE_SEC", "FORM9_A4_D_SHARES_STATE_COOP",
+ "FORM9_A4_E_SHARES_DCCB", "FORM9_A4_F_SHARES_OTHER_COOP", "FORM9_A4_G1_FD_SBI",
+ "FORM9_A4_G2_FD_BANKING_SYSTEM", "FORM9_A4_G3_FD_STATE_COOP", "FORM9_A4_G4_FD_DCCB",
+ "FORM9_A4_G5_FD_OTHER_COOP", "FORM9_A4_H_OTHER_INVESTMENTS"));
+ final BigDecimal advancesRupees = sumRupees(byKey, "FORM9_A6_A_SEASONAL_AGRI", "FORM9_A6_B_MARKETING_CROPS",
+ "FORM9_A6_C_MEDIUM_TERM_AGRI", "FORM9_A6_D_WEAVERS_SOCIETIES", "FORM9_A6_E_INDUSTRIAL_SHORT",
+ "FORM9_A6_E_INDUSTRIAL_MEDIUM", "FORM9_A6_F_OTHER_SHORT", "FORM9_A6_F_OTHER_MEDIUM",
+ "FORM9_A6_F_OTHER_LONG");
+ totals.put("loansAndAdvances", thousands(advancesRupees));
+ totals.put("otherAssets", sumThousands(byKey, "FORM9_A11_I_TANGIBLE_ASSETS", "FORM9_A11_II_INTANGIBLE_ASSETS"));
+ totals.put("totalAssets", thousands(assetTotal));
+
+ // The form's two explanation percentages, computed from the advances they qualify.
+ final BigDecimal overdueRupees = rupeesOf(byKey, "FORM9_A6_G_OVERDUE_ADVANCES");
+ final BigDecimal priorityRupees = rupeesOf(byKey, "FORM9_A6_G_PRIORITY_SECTOR");
+ totals.put("overduePercentage", percentageOf(overdueRupees, advancesRupees));
+ totals.put("prioritySectorPercentage", percentageOf(priorityRupees, advancesRupees));
+ response.put("totals", totals);
+
+ // ── Reconciliation ──
+ //
+ // Stated whether or not it holds. A return whose sides disagree is a return with something
+ // unmapped or double-mapped, and saying so is the only way anyone finds out which.
+ final BigDecimal differenceRupees = liabilityTotal.subtract(assetTotal);
+ final Map reconciliation = new LinkedHashMap<>();
+ reconciliation.put("totalLiabilities", thousands(liabilityTotal));
+ reconciliation.put("totalAssets", thousands(assetTotal));
+ reconciliation.put("differenceInRupees", differenceRupees);
+ reconciliation.put("difference", thousands(differenceRupees));
+ reconciliation.put("balanced", differenceRupees.compareTo(BigDecimal.ZERO) == 0);
+ response.put("reconciliation", reconciliation);
+
+ final List caveats = new ArrayList<>();
+ if (differenceRupees.compareTo(BigDecimal.ZERO) != 0) {
+ caveats.add("The two sides of the return do not agree: liabilities exceed assets by "
+ + rupees(differenceRupees) + " rupees. A head is unmapped, mapped twice, or mapped to the "
+ + "wrong side. The figures are left as the ledger reports them.");
+ }
+
+ // A liability with a negative balance, or an asset with one, is always a ledger fault - the
+ // bank cannot owe less than nothing, nor hold less than nothing. It arrives as an ordinary
+ // figure on the face of the return and reads as a small number rather than as a broken one,
+ // and it is exactly the kind of thing that puts the two sides out of agreement. Named here
+ // so whoever reads the return knows which account to look at.
+ for (final Map.Entry> keyed : byKey.entrySet()) {
+ // The profit and loss heads are exempt. A bank that has made a loss holds it on an
+ // equity account as a debit, and that account reads negative for a perfectly good
+ // reason - the return has already moved the figure to item 12 on the asset side, where
+ // it belongs. Flagging it would report the year's loss as a ledger fault.
+ if ("FORM9_L9_BALANCE_OF_PROFIT".equals(keyed.getKey())
+ || "FORM9_A12_BALANCE_OF_LOSS".equals(keyed.getKey())) {
+ continue;
+ }
+ final Map row = keyed.getValue();
+ final String usage = (String) row.get("usage");
+ final String side = USAGE_LIABILITY.equals(usage) ? "A liability" : USAGE_ASSET.equals(usage) ? "An asset" : "A head";
+
+ // Each ACCOUNT, not only the head's total. A negative account inside a head that totals
+ // positive is invisible in the total - "4(d) Other Demand Liabilities" reads 140,826
+ // while one of the four accounts under it sits at minus a thousand - and that is the
+ // case most worth catching, because nothing about the printed figure looks wrong.
+ @SuppressWarnings("unchecked")
+ final List> accounts = (List>) row.get("accounts");
+ boolean namedAnAccount = false;
+ if (accounts != null) {
+ for (final Map account : accounts) {
+ final BigDecimal balance = (BigDecimal) account.get("balanceInRupees");
+ if (balance == null || balance.compareTo(BigDecimal.ZERO) >= 0) {
+ continue;
+ }
+ namedAnAccount = true;
+ final String where = USAGE_LIABILITY.equals(usage)
+ // Already carried to the asset side above, so the note says where it
+ // went rather than asking the reader to go and find it.
+ ? " It is reported at 11(i) Other Assets, because a liability paid past "
+ + "nil is money owed back to the bank."
+ : "";
+ caveats.add(side + " has been drawn past nil: " + account.get("glCode") + " "
+ + account.get("name") + ", under \"" + row.get("label") + "\", stands at "
+ + rupees(balance) + " rupees. Either an entry was posted to it that should have "
+ + "gone elsewhere, or the entry that should have raised it never ran."
+ + where);
+ }
+ }
+
+ // The head itself, where no account under it accounts for the sign - a derived figure,
+ // or a head whose accounts are individually fine but sum below zero.
+ final BigDecimal headBalance = (BigDecimal) row.get("amountInRupees");
+ if (!namedAnAccount && headBalance != null && headBalance.compareTo(BigDecimal.ZERO) < 0) {
+ caveats.add(side + " cannot hold a negative balance: \"" + row.get("label") + "\" reports "
+ + rupees(headBalance) + " rupees. Either an entry was posted to it that should have gone "
+ + "elsewhere, or the entry that should have raised it never ran.");
+ }
+ }
+ // Named, not counted. A difference tells a filer THAT something is missing; this tells them
+ // WHICH account, which is the only form of the answer they can act on. Without it the gap
+ // has to be hunted down by reading the trial balance against the return line by line - and
+ // it comes back every time somebody opens a GL account and forgets to map it.
+ for (final Map account : unmappedAccountsWithBalances(asOn, officeId, byKey)) {
+ caveats.add("Not on this return: " + account.get("glCode") + " " + account.get("name") + " holds "
+ + rupees((java.math.BigDecimal) account.get("balance")) + " rupees but is mapped to no head, so it is counted on "
+ + "neither side. Map it under Admin, or the return stays out by this amount.");
+ }
+
+ if (unmappedCount > 0) {
+ // Not an error. A nil line is a real answer on this return, so this says what it is -
+ // how much of the form has been configured - rather than calling it a failure.
+ caveats.add(unmappedCount + " of " + roles.size() + " heads have no GL account mapped and "
+ + "report nil. On this return a nil line is a real answer; map any head the bank "
+ + "does hold a balance under.");
+ }
+ if (!monthComplete) {
+ caveats.add("The month has not closed. This is the position so far, not the month-end "
+ + "position the return reports.");
+ }
+ response.put("caveats", caveats);
+ response.put("computable", true);
+
+ return gson.toJson(response);
+ }
+
+ /**
+ * Rupees to rupees-thousands, rounded half-up.
+ *
+ *
+ * Called on a finished total and never on an individual account. Rounding each account and then
+ * adding the results drifts by up to half a thousand per account, and on the filed return this
+ * was built from that drift put the two sides 2 thousand apart - which the report then hid
+ * behind a "DIFF ADJUSTED" line.
+ */
+ /**
+ * Every balance-sheet account with a balance, and which head of this return reports it.
+ *
+ *
+ * A trial balance read through the return's own eyes. It exists because a difference between the
+ * two sides can only be chased by comparing what the ledger holds against what the return counts,
+ * and doing that by hand across a chart of accounts is where the time goes. Every figure here is
+ * read by the same code the return itself uses, so the two cannot disagree about an account.
+ */
+ @GET
+ @Path("form9-trial-balance")
+ public String form9TrialBalance(@QueryParam("reportingDate") final String reportingDateParam,
+ @QueryParam("officeId") final Long officeId) {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
+
+ final LocalDate asOn = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), "The reporting date")
+ .with(TemporalAdjusters.lastDayOfMonth());
+
+ // Which head, if any, claims each account - an account can be claimed by more than one, and
+ // that is itself worth seeing.
+ final Map> heads = new LinkedHashMap<>();
+ for (final Map row : jdbcTemplate.queryForList("""
+ SELECT c.gl_account_id, r.config_key, c.weight_percentage
+ FROM m_rbi_reserve_gl_config c
+ JOIN m_rbi_reserve_role r ON r.config_key = c.config_key
+ WHERE r.role_group = ?
+ """, GROUP_FORM9)) {
+ final Long accountId = ((Number) row.get("gl_account_id")).longValue();
+ heads.computeIfAbsent(accountId, k -> new ArrayList<>())
+ .add(row.get("config_key") + " @" + row.get("weight_percentage") + "%");
+ }
+
+ final StringBuilder sql = new StringBuilder("""
+ SELECT a.id, a.gl_code, a.name, a.classification_enum,
+ COALESCE(SUM(CASE WHEN j.type_enum = 2 THEN j.amount ELSE 0 END), 0) AS total_debit,
+ COALESCE(SUM(CASE WHEN j.type_enum = 1 THEN j.amount ELSE 0 END), 0) AS total_credit
+ FROM acc_gl_account a
+ JOIN acc_gl_journal_entry j ON j.account_id = a.id
+ WHERE a.account_usage = 1
+ AND j.entry_date <= ?
+ AND (j.reversed IS NULL OR j.reversed = false)
+ AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
+ """);
+ final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn)));
+ if (officeId != null) {
+ sql.append(" AND j.office_id = ?");
+ args.add(officeId);
+ }
+ sql.append(" GROUP BY a.id, a.gl_code, a.name, a.classification_enum ORDER BY a.gl_code");
+
+ final List> accounts = new ArrayList<>();
+ BigDecimal assets = BigDecimal.ZERO;
+ BigDecimal liabilities = BigDecimal.ZERO;
+ BigDecimal equity = BigDecimal.ZERO;
+ BigDecimal income = BigDecimal.ZERO;
+ BigDecimal expense = BigDecimal.ZERO;
+
+ for (final Map row : jdbcTemplate.queryForList(sql.toString(), args.toArray())) {
+ final int classification = ((Number) row.get("classification_enum")).intValue();
+ final BigDecimal debit = (BigDecimal) row.get("total_debit");
+ final BigDecimal credit = (BigDecimal) row.get("total_credit");
+ final boolean debitNatured = classification == ASSET || classification == EXPENSE;
+ final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit);
+ if (balance.compareTo(BigDecimal.ZERO) == 0) {
+ continue;
+ }
+ switch (classification) {
+ case 1 -> assets = assets.add(balance);
+ case 2 -> liabilities = liabilities.add(balance);
+ case 3 -> equity = equity.add(balance);
+ case 4 -> income = income.add(balance);
+ default -> expense = expense.add(balance);
+ }
+ final Long id = ((Number) row.get("id")).longValue();
+ final Map out = new LinkedHashMap<>();
+ out.put("glCode", row.get("gl_code"));
+ out.put("name", row.get("name"));
+ out.put("classification", switch (classification) {
+ case 1 -> "ASSET";
+ case 2 -> "LIABILITY";
+ case 3 -> "EQUITY";
+ case 4 -> "INCOME";
+ default -> "EXPENSE";
+ });
+ out.put("balance", balance);
+ out.put("reportedUnder", heads.getOrDefault(id, List.of()));
+ accounts.add(out);
+ }
+
+ final Map response = new LinkedHashMap<>();
+ response.put("asOnDate", asOn.toString());
+ response.put("accounts", accounts);
+ final Map totals = new LinkedHashMap<>();
+ totals.put("assets", assets);
+ totals.put("liabilities", liabilities);
+ totals.put("equity", equity);
+ totals.put("income", income);
+ totals.put("expense", expense);
+ totals.put("tradingResult", income.subtract(expense));
+ // Assets must equal liabilities plus equity plus the year's result. Where they do not, the
+ // ledger itself is out, and no return built on it can balance.
+ totals.put("assetsLessLiabilitiesEquityAndResult",
+ assets.subtract(liabilities).subtract(equity).subtract(income.subtract(expense)));
+ response.put("totals", totals);
+ return gson.toJson(response);
+ }
+
+ /**
+ * The trading result the books have not yet closed: income less expense, as at the given date.
+ *
+ *
+ * Read from the income and expense accounts themselves rather than from any equity account. No
+ * balance-sheet head maps them, and rightly so - they are not balance-sheet accounts - but their
+ * net IS the profit or loss the balance sheet closes with. Left out, the two sides of the return
+ * disagree by exactly the year's result, and the figure that is missing is the one a reader
+ * would most want.
+ *
+ *
+ * No date range, and no rule about which year an entry belongs to. What matters is what the
+ * accounts still HOLD: a year-end closing entry nets the account it closes back to zero, so
+ * whatever has been closed already contributes nothing here, and whatever has not is still owed
+ * a place on the balance sheet however long ago it was earned. The ledger itself says which is
+ * which, and two attempts to say it another way were both wrong - filtering from 1 April left
+ * out earlier earnings that were never closed, and skipping entries with an equity leg also
+ * skipped the original income, whose own posting often carries one.
+ *
+ * @return income minus expense; negative is a loss.
+ */
+ /**
+ * Balance-sheet accounts holding a balance that no head on this return maps.
+ *
+ *
+ * Every such account is a hole in the return of exactly its own balance, and the two sides
+ * cannot agree while one exists. The return already reports the difference; this says which
+ * account caused it, which is the only form of the answer anyone can act on.
+ *
+ *
+ * Income and expense accounts are excluded deliberately. They are not balance-sheet accounts and
+ * no head maps them; their net is reported as the year's result instead.
+ */
+ private List> unmappedAccountsWithBalances(final LocalDate asOn, final Long officeId,
+ final Map> byKey) {
+ // Everything this return already accounts for, however many heads it is spread across.
+ final Set mapped = new HashSet<>();
+ for (final Map row : byKey.values()) {
+ @SuppressWarnings("unchecked")
+ final List> accounts = (List>) row.get("accounts");
+ if (accounts == null) {
+ continue;
+ }
+ for (final Map account : accounts) {
+ final Object id = account.get("glAccountId");
+ if (id != null) {
+ mapped.add(((Number) id).longValue());
+ }
+ }
+ }
+
+ final StringBuilder sql = new StringBuilder("""
+ SELECT a.id, a.gl_code, a.name, a.classification_enum,
+ COALESCE(SUM(CASE WHEN j.type_enum = 2 THEN j.amount ELSE -j.amount END), 0) AS debit_less_credit
+ FROM acc_gl_account a
+ JOIN acc_gl_journal_entry j ON j.account_id = a.id
+ WHERE a.classification_enum IN (1, 2, 3)
+ AND a.account_usage = 1
+ AND j.entry_date <= ?
+ AND (j.reversed IS NULL OR j.reversed = false)
+ AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
+ """);
+ final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn)));
+ if (officeId != null) {
+ sql.append(" AND j.office_id = ?");
+ args.add(officeId);
+ }
+ sql.append(" GROUP BY a.id, a.gl_code, a.name, a.classification_enum ORDER BY a.gl_code");
+
+ final List> out = new ArrayList<>();
+ for (final Map row : jdbcTemplate.queryForList(sql.toString(), args.toArray())) {
+ final Long id = ((Number) row.get("id")).longValue();
+ if (mapped.contains(id)) {
+ continue;
+ }
+ final int classification = ((Number) row.get("classification_enum")).intValue();
+ final BigDecimal debitLessCredit = (BigDecimal) row.get("debit_less_credit");
+ // On its natural side, so the figure reads the way the account itself does.
+ final BigDecimal balance = classification == ASSET ? debitLessCredit : debitLessCredit.negate();
+ if (balance.compareTo(BigDecimal.ZERO) == 0) {
+ continue;
+ }
+ final Map account = new LinkedHashMap<>();
+ account.put("glCode", row.get("gl_code"));
+ account.put("name", row.get("name"));
+ account.put("balance", balance);
+ out.add(account);
+ }
+ return out;
+ }
+
+ private BigDecimal unclosedTradingResult(final LocalDate asOn, final Long officeId) {
+ // Income is credit-natured and expense debit-natured, so each is netted on its own side and
+ // the expense side subtracted: a credit to income adds to the result, a debit to expense
+ // takes from it.
+ final StringBuilder sql = new StringBuilder("""
+ SELECT COALESCE(SUM(
+ CASE WHEN a.classification_enum = 4
+ THEN CASE WHEN j.type_enum = 1 THEN j.amount ELSE -j.amount END
+ ELSE CASE WHEN j.type_enum = 2 THEN -j.amount ELSE j.amount END
+ END), 0)
+ FROM acc_gl_journal_entry j
+ JOIN acc_gl_account a ON a.id = j.account_id
+ WHERE a.classification_enum IN (4, 5)
+ AND j.entry_date <= ?
+ AND (j.reversed IS NULL OR j.reversed = false)
+ AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
+ """);
+ final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn)));
+ if (officeId != null) {
+ sql.append(" AND j.office_id = ?");
+ args.add(officeId);
+ }
+ final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray());
+ return result == null ? BigDecimal.ZERO : result;
+ }
+
+ /**
+ * A head's accounts as the ledger holds them, with the unclosed trading result added as a row
+ * of its own.
+ *
+ *
+ * The balances are not restated. Flipping their signs to match the side the head is reported on
+ * was tried and reads as double counting: the same account then appears under item 9 at -512 and
+ * under item 12 at +512, and nothing on the form says they are one balance. A loss genuinely IS
+ * a debit balance on an equity account, so showing it as one is the honest reading, and the
+ * head's own total already states the figure the right way up for the side it sits on.
+ *
+ *
+ * The trading result appears as its own row because no account holds it: it is the net of the
+ * income and expense accounts, which the books have not closed to equity. Without it the rows
+ * are short by exactly that, and a reviewer adding them up finds a figure that is not the head.
+ */
+ private static List> withTradingRow(final Object source, final BigDecimal tradingResult) {
+ final List> out = new ArrayList<>();
+ if (source instanceof List> accounts) {
+ for (final Object item : accounts) {
+ if (item instanceof Map, ?> account) {
+ final Map row = new LinkedHashMap<>();
+ for (final Map.Entry, ?> field : account.entrySet()) {
+ row.put(String.valueOf(field.getKey()), field.getValue());
+ }
+ out.add(row);
+ }
+ }
+ }
+ if (tradingResult != null && tradingResult.compareTo(BigDecimal.ZERO) != 0) {
+ final Map row = new LinkedHashMap<>();
+ row.put("glAccountId", null);
+ row.put("glCode", "—");
+ row.put("name", "Income and expense accounts not yet closed to equity");
+ row.put("balanceInRupees", tradingResult);
+ row.put("balance", thousands(tradingResult));
+ out.add(row);
+ }
+ return out;
+ }
+
+ /**
+ * A rupee figure for a sentence a person reads.
+ *
+ *
+ * Trimmed to paise. A BigDecimal carries whatever scale the arithmetic that produced it left
+ * behind, and a note that says "-247784.820000 rupees" reads as a machine's working rather than
+ * as an amount of money.
+ */
+ private static String rupees(final BigDecimal amount) {
+ return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
+ }
+
+ private static BigDecimal thousands(final BigDecimal rupees) {
+ return rupees == null ? null : rupees.divide(THOUSAND, 0, RoundingMode.HALF_UP);
+ }
+
+ /** One head's rupee figure, or zero where it is unmapped. */
+ private static BigDecimal rupeesOf(final Map> byKey, final String configKey) {
+ final Map row = byKey.get(configKey);
+ if (row == null) {
+ return BigDecimal.ZERO;
+ }
+ final BigDecimal amount = (BigDecimal) row.get("amountInRupees");
+ return amount == null ? BigDecimal.ZERO : amount;
+ }
+
+ /** Several heads added in rupees, for a total that is converted once afterwards. */
+ private static BigDecimal sumRupees(final Map> byKey, final String... configKeys) {
+ BigDecimal total = BigDecimal.ZERO;
+ for (final String key : configKeys) {
+ total = total.add(rupeesOf(byKey, key));
+ }
+ return total;
+ }
+
+ /** Several heads added in rupees and stated in thousands. */
+ private static BigDecimal sumThousands(final Map> byKey, final String... configKeys) {
+ return thousands(sumRupees(byKey, configKeys));
+ }
+
+ /**
+ * A head's accounts with their balances restated in thousands.
+ *
+ *
+ * These are the rows of the form's "specify" sheets - Other Funds and Reserves, Other Demand
+ * Liabilities, Other Time Liabilities, Other Investments. Each account mapped to such a head IS
+ * a row on its sheet, under its own name, so no separate machinery is needed to build them.
+ */
+ private static List> accountsInThousands(final RoleFigure figure) {
+ final List> out = new ArrayList<>();
+ for (final Map account : figure.accounts) {
+ final Map row = new LinkedHashMap<>(account);
+ final BigDecimal balance = (BigDecimal) account.get("balance");
+ // Both kept: the rupee balance is what reconciles against the ledger, the thousands
+ // figure is what the sheet prints. An account row rounds on its own because it is
+ // printed on its own - it is never added into a total here.
+ row.put("balanceInRupees", balance);
+ row.put("balance", thousands(balance));
+ out.add(row);
+ }
+ return out;
+ }
}
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
index ab09e9143..3a8f0dea2 100644
--- 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
@@ -53,7 +53,10 @@ import org.springframework.transaction.annotation.Transactional;
@Slf4j
public class RbiFormAAutoSaveService {
- private static final String FORM_CODE = "FORM_1";
+ // The statutory form, because that is the Form 1 the screen shows and the one that gets filed.
+ // Saving the working report's shape left rows in the list that opened as a blank statement:
+ // the viewer built for the statutory form found none of the fields it reads.
+ private static final String FORM_CODE = "FORM_1_STATUTORY";
private final JdbcTemplate jdbcTemplate;
private final RbiReserveReturnApiResource returnApiResource;
@@ -89,7 +92,11 @@ public class RbiFormAAutoSaveService {
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);
+ // No column dates passed: today IS a fortnight close - the check above returned
+ // otherwise - so the form derives its own pair from it, which is the pair an unattended
+ // run should file. A filer needing a different period enters both dates on the screen.
+ report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, 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
@@ -98,18 +105,22 @@ public class RbiFormAAutoSaveService {
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.",
+ // The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil
+ // on it - a bank with no balances at the State Co-operative Bank files that line empty - so
+ // the form is filed as it stands. What it cannot do without is a rate, because every
+ // required amount below is a percentage of one, and saving a return whose requirement is
+ // zero for want of configuration would put a compliant-looking statement on the record.
+ if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) {
+ log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.",
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");
- }
+ // The form's period runs between its two fortnight closes, and its headline figures sit per
+ // column inside the parts rather than once at the top.
+ final LocalDate periodStart = column(report, 0);
+ final LocalDate periodEnd = column(report, 1);
+ final BigDecimal average = null;
jdbcTemplate.update("""
INSERT INTO m_rbi_form_a_filing
@@ -119,9 +130,9 @@ public class RbiFormAAutoSaveService {
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.");
+ gson.toJson(report), partFigure(report, "partA", "baseDateLiabilities"),
+ partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average,
+ decimal(report, "crrPercentage"), "Saved automatically at the close of the fortnight.");
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
}
@@ -143,6 +154,41 @@ public class RbiFormAAutoSaveService {
return object.get(field).getAsBigDecimal();
}
+ /**
+ * One of the statutory form's per-column figures, at the close the return is made up to.
+ *
+ * Index 1, the second column: the third is the base the ratios are charged on, not a later
+ * position, and listing it would show a requirement against a fortnight nobody filed.
+ */
+ private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
+ if (!report.has(part) || !report.get(part).isJsonObject()) {
+ return null;
+ }
+ final JsonObject partObject = report.getAsJsonObject(part);
+ if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
+ return null;
+ }
+ final var values = partObject.getAsJsonArray(field);
+ if (values.size() < 2 || values.get(1).isJsonNull()) {
+ return null;
+ }
+ return values.get(1).getAsBigDecimal();
+ }
+
+ /** One of the form's column dates. */
+ private static LocalDate column(final JsonObject report, final int index) {
+ if (!report.has("columns") || !report.get("columns").isJsonArray()) {
+ return null;
+ }
+ final var columns = report.getAsJsonArray("columns");
+ if (columns.size() <= index || columns.get(index).isJsonNull()) {
+ return null;
+ }
+ final String value = columns.get(index).getAsString();
+ return value == null || value.isBlank() ? null : LocalDate.parse(value);
+ }
+
+ @SuppressWarnings("unused")
private static LocalDate date(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java
index f3d98a4a1..f66b2485a 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java
@@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionTemplate;
/**
- * Runs the day: accrue for every account, then pay out any product whose cycle ended today.
+ * Runs the day's savings interest: accrue for every account, and post interest only from approved
+ * interest posting reports ({@link SavingsInterestPostingReportService}).
*
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
@@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator {
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService;
+ private final SavingsInterestPostingReportService reportService;
+ private final PlatformTransactionManager transactionManager;
/**
- * Accrue for the day, then post for any product whose posting date has arrived.
+ * The nightly run: settles any approved interest report still unposted, then accrues the day.
*
- * Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
- * posting then reads and updates those same rows in a transaction of its own
- * ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
- * another's payment). Holding an outer transaction across both would mean this thread still owns
- * the row locks the accrual took while the posting's separate transaction waits for them — each
- * waiting on the other, and the job hanging silently with nothing in the log. So each step
- * commits on its own before the next begins.
+ *
+ * This run no longer decides to pay anybody. Interest is paid only from an approved posting
+ * report, normally when the last branch closes the day. What is left here is the safety net for a
+ * report that was approved but could not be posted then, and it runs BEFORE the accrual on
+ * purpose: by the time this job runs the business date has already moved past the posting date,
+ * and the accrual rolls each account's ledger row into the next cycle, which would leave nothing
+ * for the posting to settle.
+ *
+ *
+ * Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the
+ * accrual then commits on its own, so neither holds the other's row locks.
*/
public void runForDate(final LocalDate businessDate) {
clearStaleRunningFlag();
+ final List failures = postApprovedReports(businessDate);
accrualService.accrueForDate(businessDate);
enrolNewProducts();
- final List> schedules = jdbcTemplate.queryForList("""
- SELECT s.savings_product_id, s.posting_period_enum
- FROM m_savings_interest_posting_schedule s
- JOIN m_savings_product p ON p.id = s.savings_product_id
- WHERE s.is_active = true AND p.deposit_type_enum = 100
- ORDER BY s.savings_product_id
- """);
-
- // Products that could not be paid, collected so the run can fail loudly at the end.
- //
- // Carrying on past one product is right — a mapping missing on one product must not stop
- // every member of every other from being paid. Reporting the job as successful afterwards is
- // not: the scheduler recorded 'success', the operator saw a green run, and the only trace
- // that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
- // nothing reads. On a night when every product fails, that is a whole day of interest
- // silently unpaid.
- final List failures = new java.util.ArrayList<>();
-
- for (final Map schedule : schedules) {
- final long productId = ((Number) schedule.get("savings_product_id")).longValue();
-
- // Whether anything is due is decided inside postForProduct, not here.
- //
- // This used to pre-check with its own COUNT over m_savings_daily_interest, and that
- // deadlocked the run: accrueForDate above is @Transactional and has just written every
- // ledger row, so its transaction still holds them; the COUNT then read those rows on
- // this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
- // transaction of its own — sat waiting for a lock this thread was holding and would only
- // release after postForProduct returned. Each waited on the other and the job hung
- // silently, with nothing in the log and the flag left saying it was still running.
- //
- // postForProduct already selects what is due and returns zero when there is nothing, so
- // the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
- // than by the run date, which is what makes a missed posting date catch up on a later
- // run instead of being skipped for good.
- try {
- final BigDecimal paid = postingService.postForProduct(productId, businessDate);
- if (paid.signum() == 0) {
- updateSchedule(productId, businessDate, null);
- continue;
- }
- updateSchedule(productId, businessDate, "COMPLETED");
- log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
- } catch (final RuntimeException e) {
- // The product rolled back on its own; record why and let the others run.
- log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
- // An UPDATE, so it writes nothing when the product threw before openRun() inserted
- // the row — which is exactly what a product with no GL mapping does. The failure is
- // therefore collected in memory as well, so it cannot be lost with the breadcrumb.
- jdbcTemplate.update("""
- UPDATE m_savings_interest_posting_run
- SET status = 'FAILED', failure_reason = ?
- WHERE savings_product_id = ? AND posting_date = ?
- """, truncate(e.getMessage()), productId, businessDate);
- updateSchedule(productId, businessDate, "FAILED");
- failures.add("product " + productId + ": " + e.getMessage());
- }
+ // Keeps each product's next posting date on the timetable in step with the ledger, which
+ // the posting loop used to do as a side effect of looking for work.
+ for (final Long productId : jdbcTemplate.queryForList(
+ "SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) {
+ updateSchedule(productId, businessDate, null);
}
- // Fails the job so the scheduler records it as failed and an operator is told. Every product
- // that could be paid has already been paid and committed by this point — this reports what
- // happened, it does not undo it.
+ // Fails the job so the scheduler records it as failed and an operator is told. Every report
+ // that could be paid has already been paid and committed by this point.
if (!failures.isEmpty()) {
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
- + failures.size() + " product(s): " + String.join("; ", failures));
+ + failures.size() + " report(s): " + String.join("; ", failures));
}
}
+ /**
+ * Posts every approved interest posting report dated on or before the date.
+ *
+ *
+ * Each report posts in a new transaction of its own. That is required, not a preference: this is
+ * called from the day close's after-commit callback, where the closing transaction has finished
+ * but is still bound to the thread, and joining it would write nothing that ever commits. It also
+ * keeps one product's failure from rolling back another's payment. A report that fails stays
+ * APPROVED with the reason recorded, and the nightly run tries it again.
+ *
+ * @return one message per report that could not be posted
+ */
+ public List postApprovedReports(final LocalDate upToDate) {
+ return postReports(reportService.approvedReportIds(upToDate));
+ }
+
+ /**
+ * Posts the given approved reports, each in a new transaction of its own (see
+ * {@link #postApprovedReports}). Used by the day close for one branch's reports.
+ *
+ * @return one message per report that could not be posted
+ */
+ public List postReports(final List reportIds) {
+ final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager);
+ newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+
+ final List failures = new java.util.ArrayList<>();
+ for (final Long reportId : reportIds) {
+ final Map report = jdbcTemplate.queryForMap(
+ "SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId);
+ final long productId = ((Number) report.get("savings_product_id")).longValue();
+ final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
+ try {
+ final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId));
+ newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED"));
+ log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate);
+ } catch (final RuntimeException e) {
+ log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e);
+ newTransaction.executeWithoutResult(status -> {
+ reportService.recordPostingFailure(reportId, e.getMessage());
+ jdbcTemplate.update("""
+ UPDATE m_savings_interest_posting_run
+ SET status = 'FAILED', failure_reason = ?
+ WHERE report_id = ?
+ """, truncate(e.getMessage()), reportId);
+ updateSchedule(productId, postingDate, "FAILED");
+ });
+ failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
+ }
+ }
+ return failures;
+ }
+
/**
* Releases this job's own currently_running flag if an earlier run left it set.
*
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java
index dbd2fd400..22332b375 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java
@@ -243,7 +243,7 @@ public class SavingsDailyInterestService {
carriedInterest = backfill.cumulativeInterest();
} else {
final Map prev = previous.get(0);
- openingBalance = (BigDecimal) prev.get("closing_balance");
+ final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
// A cycle is finished either because it was paid out or because its end date has gone
@@ -251,11 +251,24 @@ public class SavingsDailyInterestService {
// run was missed, or the account was accrued again after a break - and treating it as
// unfinished would leave the row in a cycle that ended before the day it records.
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
- carriedInterest = BigDecimal.ZERO;
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
cycleStart = prevCycleEnd.plusDays(1);
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth);
+ // The new cycle may already be days old: the business date moved on by more than a
+ // day, or nights were missed. Those days earned too. Starting the cycle at zero and
+ // counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of
+ // October. They are replayed from the account's transactions, as a first row is, and
+ // the opening balance comes from the same replay so money moved in the gap counts.
+ if (cycleStart.isBefore(businessDate)) {
+ final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
+ minBalance, averageDailyBalanceOf(calcType));
+ openingBalance = gap.closingBalance();
+ carriedInterest = gap.cumulativeInterest();
+ } else {
+ openingBalance = (BigDecimal) prev.get("closing_balance");
+ carriedInterest = BigDecimal.ZERO;
+ }
} else {
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
@@ -274,12 +287,15 @@ public class SavingsDailyInterestService {
// So the gap is replayed from the account's own transactions, the same way a first
// row is built, and the result stands in for the running total the ledger would be
// holding had it run every night.
- final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
+ // The replay's balance, not the stale row's: deposits made during the gap were
+ // otherwise left out of every balance from here on.
+ openingBalance = gap.closingBalance();
carriedInterest = gap.cumulativeInterest();
} else {
+ openingBalance = (BigDecimal) prev.get("closing_balance");
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
}
}
@@ -506,7 +522,11 @@ public class SavingsDailyInterestService {
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
if (!cycleStart.isBefore(upTo)) {
- return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
+ // No day of the cycle to replay — it opens today — but the money held going into it is
+ // still there. Returning a zero balance made every re-accrual on a cycle's first day (a
+ // deposit, or the noon report, on the 1st) open the account at nil: an account holding
+ // 11,503.15 then earned only on what was deposited that day.
+ return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO);
}
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java
new file mode 100644
index 000000000..e3a7999c5
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java
@@ -0,0 +1,578 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.savings.dailyinterest;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * The interest posting report: what each savings account of one branch will be paid on its product's
+ * posting date, put in front of that branch before any money moves.
+ *
+ *
+ * One report per branch and product. On a posting date the noon job generates one for every branch
+ * holding accounts with interest due; a user of that branch may generate or regenerate it at any time
+ * — the previous report is superseded, not overwritten — and approves it. The branch's approved
+ * reports are posted when that branch closes its day, and until they are approved it cannot close.
+ * The head office closes last, once every branch open that day has closed.
+ *
+ *
+ * Only the branch acts on its own reports. The single exception is a branch that did not open that
+ * day: the head office may then generate and approve its report on its behalf, and posts it at the
+ * head office's own close. Every entry is still raised in the branch the customers belong to.
+ *
+ *
+ * The figures are the daily ledger's own: each account's running interest for the cycle, rounded to
+ * two decimals, with the paise the rounding loses given to the largest balance so the report's total
+ * is the branch's pooled interest exactly.
+ */
+@Service
+@Slf4j
+public class SavingsInterestPostingReportService {
+
+ public static final String GENERATED = "GENERATED";
+ public static final String APPROVED = "APPROVED";
+ public static final String POSTED = "POSTED";
+ public static final String SUPERSEDED = "SUPERSEDED";
+
+ private static final int MONEY_SCALE = 2;
+ private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
+ /** m_savings_account.status_enum for an active account. */
+ private static final int ACTIVE = 300;
+
+ /** A product with interest due in one branch. */
+ private record DueKey(Long productId, Long officeId) {}
+
+ private final JdbcTemplate jdbcTemplate;
+ private final SavingsDailyInterestService accrualService;
+ private final SavingsPooledInterestPostingService postingService;
+ private final TransactionTemplate transactionTemplate;
+
+ public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService,
+ final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) {
+ this.jdbcTemplate = jdbcTemplate;
+ this.accrualService = accrualService;
+ this.postingService = postingService;
+ // Always a transaction of its own. The noon job runs inside the batch step's transaction, and
+ // generating in it would read the accrual through that uncommitted transaction and hold its
+ // row locks for the whole run; a report committed on its own is also visible to the screen at once.
+ this.transactionTemplate = new TransactionTemplate(transactionManager);
+ this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+ }
+
+ // ── generation ───────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * The noon job: brings the ledger up to the business date, then generates a report for every
+ * branch and product with interest due that does not already have one.
+ *
+ *
+ * Each report is generated in its own transaction, so one that cannot be — a GL mapping missing,
+ * say — does not stop the others. The failures are raised together at the end so the job is
+ * recorded as failed.
+ *
+ * @return the ids of the reports generated
+ */
+ public List generateDue(final LocalDate postingDate) {
+ // So each account's figure includes the posting date itself, on the balance it holds now.
+ transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate));
+
+ final List generated = new ArrayList<>();
+ final List failures = new ArrayList<>();
+ for (final DueKey due : dueKeys(postingDate, null)) {
+ if (activeReportId(due.productId(), due.officeId(), postingDate) != null) {
+ continue;
+ }
+ try {
+ generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null)));
+ } catch (final RuntimeException e) {
+ log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(),
+ due.officeId(), postingDate, e);
+ failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage());
+ }
+ }
+ if (!failures.isEmpty()) {
+ throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for "
+ + failures.size() + " report(s): " + String.join("; ", failures));
+ }
+ return generated;
+ }
+
+ /**
+ * Generates the report afresh for one branch and product, on the user's request.
+ *
+ *
+ * Allowed at any time of day; only the noon job waits for noon. Not for a date after the business
+ * date, whose interest has not been earned yet. A posted report cannot be regenerated: its money
+ * has moved. An approved one can, and has to be approved again.
+ */
+ public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) {
+ final LocalDate businessDate = DateUtils.getBusinessLocalDate();
+ if (postingDate.isAfter(businessDate)) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date",
+ "The report for " + postingDate + " cannot be generated before that date.");
+ }
+ authorize(user, officeId, businessDate);
+ transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
+ return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId()));
+ }
+
+ /**
+ * Approves a generated report. A user of the report's branch approves it; the head office may
+ * approve it only for a branch that did not open that day, and the report records that it did.
+ */
+ public void approve(final Long reportId, final AppUser user) {
+ final List> found = jdbcTemplate
+ .queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId);
+ if (found.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
+ "Interest posting report " + reportId + " does not exist.");
+ }
+ final Long officeId = ((Number) found.get(0).get("office_id")).longValue();
+ final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate());
+ final int approved = jdbcTemplate.update("""
+ UPDATE m_savings_interest_posting_report
+ SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ?
+ WHERE id = ? AND status = 'GENERATED'
+ """, user.getId(), onBehalf, reportId);
+ if (approved == 0) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated",
+ "Only a report in GENERATED status can be approved. Reload the report — it may have been approved, "
+ + "regenerated or posted already.");
+ }
+ }
+
+ // ── day close ────────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * What stops one branch from closing the day: a product with interest due in the branch and no
+ * approved (or posted) report for the date, and any older report of the branch still awaiting
+ * approval — one left from a day the branch did not open. A report that was never generated
+ * blocks the close too.
+ */
+ public List reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) {
+ final Set blocking = new LinkedHashSet<>();
+ for (final DueKey due : dueKeys(businessDate, officeId)) {
+ final Long settled = jdbcTemplate.queryForObject("""
+ SELECT COUNT(*) FROM m_savings_interest_posting_report
+ WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED')
+ """, Long.class, due.productId(), officeId, businessDate);
+ if (settled == null || settled == 0) {
+ blocking.add(productName(due.productId()) + " (" + businessDate + ")");
+ }
+ }
+ for (final Map pending : jdbcTemplate.queryForList("""
+ SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r
+ JOIN m_savings_product p ON p.id = r.savings_product_id
+ WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ?
+ ORDER BY r.posting_date
+ """, officeId, businessDate)) {
+ blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")");
+ }
+ return new ArrayList<>(blocking);
+ }
+
+ /** The branch's approved reports, posted when it closes the day. */
+ public List approvedReportIds(final Long officeId, final LocalDate upToDate) {
+ return jdbcTemplate.queryForList("""
+ SELECT id FROM m_savings_interest_posting_report
+ WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ?
+ ORDER BY posting_date, savings_product_id
+ """, Long.class, officeId, upToDate);
+ }
+
+ /**
+ * Approved reports of branches that did not open on the date — what the head office posts at its
+ * own close, the branches having no close of their own that day.
+ */
+ public List approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) {
+ return jdbcTemplate.queryForList("""
+ SELECT r.id FROM m_savings_interest_posting_report r
+ WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
+ AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?)
+ ORDER BY r.posting_date, r.office_id, r.savings_product_id
+ """, Long.class, businessDate, headOfficeId, businessDate);
+ }
+
+ /** Every approved report still waiting, for the nightly safety net. */
+ public List approvedReportIds(final LocalDate upToDate) {
+ return jdbcTemplate.queryForList("""
+ SELECT id FROM m_savings_interest_posting_report
+ WHERE status = 'APPROVED' AND posting_date <= ?
+ ORDER BY posting_date, office_id, savings_product_id
+ """, Long.class, upToDate);
+ }
+
+ /** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */
+ public void recordPostingFailure(final Long reportId, final String reason) {
+ jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?",
+ reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId);
+ }
+
+ // ── reading ──────────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Reports for the screen. A branch user sees only their branch; a head office user sees every
+ * branch, so it can act for one that did not open. canAct says whether this user may regenerate
+ * or approve the report.
+ */
+ public List> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
+ final AppUser user) {
+ final Long userOffice = officeOf(user);
+ final boolean headOffice = isHeadOffice(userOffice);
+ final Long branch = headOffice ? officeId : userOffice;
+
+ final StringBuilder sql = new StringBuilder("""
+ SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
+ o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest",
+ r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt",
+ gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy",
+ r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason"
+ FROM m_savings_interest_posting_report r
+ JOIN m_savings_product p ON p.id = r.savings_product_id
+ LEFT JOIN m_office o ON o.id = r.office_id
+ LEFT JOIN m_appuser gu ON gu.id = r.generated_by
+ LEFT JOIN m_appuser au ON au.id = r.approved_by
+ WHERE 1 = 1
+ """);
+ final List params = new ArrayList<>();
+ if (postingDate != null) {
+ sql.append(" AND r.posting_date = ?");
+ params.add(postingDate);
+ }
+ if (status != null && !status.isBlank()) {
+ sql.append(" AND r.status = ?");
+ params.add(status.trim().toUpperCase());
+ } else {
+ sql.append(" AND r.status <> 'SUPERSEDED'");
+ }
+ if (productId != null) {
+ sql.append(" AND r.savings_product_id = ?");
+ params.add(productId);
+ }
+ if (branch != null) {
+ sql.append(" AND r.office_id = ?");
+ params.add(branch);
+ }
+ sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
+
+ final LocalDate businessDate = DateUtils.getBusinessLocalDate();
+ final List> rows = new ArrayList<>();
+ for (final Map row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
+ final Map out = new LinkedHashMap<>(row);
+ final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
+ out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate));
+ rows.add(out);
+ }
+ return rows;
+ }
+
+ public Map detail(final Long reportId) {
+ final List> found = jdbcTemplate.queryForList("""
+ SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
+ o.name AS "officeName", r.posting_date AS "postingDate",
+ r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount",
+ r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy",
+ r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf",
+ r.posted_at AS "postedAt", r.failure_reason AS "failureReason",
+ pg.gl_code AS "payableGlCode", pg.name AS "payableGlName",
+ dg.gl_code AS "paidGlCode", dg.name AS "paidGlName",
+ eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName"
+ FROM m_savings_interest_posting_report r
+ JOIN m_savings_product p ON p.id = r.savings_product_id
+ LEFT JOIN m_office o ON o.id = r.office_id
+ LEFT JOIN m_appuser gu ON gu.id = r.generated_by
+ LEFT JOIN m_appuser au ON au.id = r.approved_by
+ LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id
+ LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id
+ LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2
+ AND em.financial_account_type = 3 AND em.payment_type IS NULL
+ LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id
+ WHERE r.id = ?
+ """, reportId);
+ if (found.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
+ "Interest posting report " + reportId + " does not exist.");
+ }
+ final Map report = new LinkedHashMap<>(found.get(0));
+ report.put("lines", jdbcTemplate.queryForList("""
+ SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName",
+ l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore",
+ l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId"
+ FROM m_savings_interest_posting_report_line l
+ WHERE l.report_id = ?
+ ORDER BY l.account_no
+ """, reportId));
+ return report;
+ }
+
+ /**
+ * Branch and product interest for the head office: per report, how many customers and how much
+ * interest, and where it stands. Superseded reports are left out — each line is the report that
+ * counts for that branch, product and date.
+ */
+ public List> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) {
+ final StringBuilder sql = new StringBuilder("""
+ SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName",
+ r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount",
+ r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy",
+ r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt"
+ FROM m_savings_interest_posting_report r
+ JOIN m_savings_product p ON p.id = r.savings_product_id
+ LEFT JOIN m_office o ON o.id = r.office_id
+ LEFT JOIN m_appuser au ON au.id = r.approved_by
+ WHERE r.status <> 'SUPERSEDED'
+ """);
+ final List params = new ArrayList<>();
+ if (fromDate != null) {
+ sql.append(" AND r.posting_date >= ?");
+ params.add(fromDate);
+ }
+ if (toDate != null) {
+ sql.append(" AND r.posting_date <= ?");
+ params.add(toDate);
+ }
+ if (productId != null) {
+ sql.append(" AND r.savings_product_id = ?");
+ params.add(productId);
+ }
+ sql.append(" ORDER BY r.posting_date DESC, p.name, o.name");
+ return jdbcTemplate.queryForList(sql.toString(), params.toArray());
+ }
+
+ /**
+ * Branch and product pairs with interest due by the date that have no report yet — the noon job ran
+ * before they had anything due, or failed. Limited to what the user may act on.
+ */
+ public List> dueWithoutReport(final LocalDate postingDate, final AppUser user) {
+ final List> out = new ArrayList<>();
+ // A future date has nothing due yet, and offering to generate for it would label this cycle's
+ // part-earned interest with a date it has not reached.
+ final LocalDate businessDate = DateUtils.getBusinessLocalDate();
+ if (postingDate.isAfter(businessDate)) {
+ return out;
+ }
+ final Long userOffice = officeOf(user);
+ for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) {
+ if (activeReportId(due.productId(), due.officeId(), postingDate) == null
+ && mayAct(userOffice, due.officeId(), businessDate)) {
+ final Map row = new LinkedHashMap<>();
+ row.put("productId", due.productId());
+ row.put("productName", productName(due.productId()));
+ row.put("officeId", due.officeId());
+ row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId()));
+ row.put("postingDate", postingDate.toString());
+ out.add(row);
+ }
+ }
+ return out;
+ }
+
+ // ── who may act ──────────────────────────────────────────────────────────────────────────────
+
+ public boolean isHeadOffice(final Long officeId) {
+ final List parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId);
+ return !parent.isEmpty() && parent.get(0) == null;
+ }
+
+ /** Whether the branch opened its day on the date — the head office may act only for one that did not. */
+ public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
+ final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
+ Long.class, officeId, businessDate);
+ return rows != null && rows > 0;
+ }
+
+ private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
+ if (userOffice == null) {
+ return false;
+ }
+ if (userOffice.equals(reportOffice)) {
+ return true;
+ }
+ return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate);
+ }
+
+ /** @return true where the head office is acting on a branch's behalf */
+ private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
+ final Long userOffice = officeOf(user);
+ if (userOffice != null && userOffice.equals(reportOffice)) {
+ return false;
+ }
+ if (mayAct(userOffice, reportOffice, businessDate)) {
+ return true;
+ }
+ final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice);
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch",
+ "Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day "
+ + branch + " has not opened.");
+ }
+
+ private static Long officeOf(final AppUser user) {
+ return user == null || user.getOffice() == null ? null : user.getOffice().getId();
+ }
+
+ // ── internals ────────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Branch and product pairs with interest due on or before the date: a savings product on the
+ * daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in
+ * accounts belonging to that branch.
+ */
+ private List dueKeys(final LocalDate postingDate, final Long officeId) {
+ final StringBuilder sql = new StringBuilder("""
+ SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id
+ FROM m_savings_daily_interest d
+ JOIN m_savings_product p ON p.id = d.savings_product_id
+ JOIN m_savings_account sa ON sa.id = d.savings_account_id
+ LEFT JOIN m_client c ON c.id = sa.client_id
+ LEFT JOIN m_group g ON g.id = sa.group_id
+ JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true
+ WHERE p.deposit_type_enum = 100
+ AND sa.status_enum = ?
+ AND d.posting_date <= ?
+ AND d.savings_transaction_id IS NULL
+ AND d.cycle_cumulative_interest > 0
+ AND COALESCE(c.office_id, g.office_id) IS NOT NULL
+ """);
+ final List params = new ArrayList<>(List.of(ACTIVE, postingDate));
+ if (officeId != null) {
+ sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
+ params.add(officeId);
+ }
+ sql.append(" ORDER BY 2, 1");
+ return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream()
+ .map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue()))
+ .toList();
+ }
+
+ private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) {
+ final List ids = jdbcTemplate.queryForList("""
+ SELECT id FROM m_savings_interest_posting_report
+ WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
+ """, Long.class, productId, officeId, postingDate);
+ return ids.isEmpty() ? null : ids.get(0);
+ }
+
+ private String productName(final Long productId) {
+ return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId);
+ }
+
+ /** Runs inside the caller's transaction. */
+ private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) {
+ final List> previous = jdbcTemplate.queryForList("""
+ SELECT id, status FROM m_savings_interest_posting_report
+ WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
+ FOR UPDATE
+ """, productId, officeId, postingDate);
+ for (final Map report : previous) {
+ if (POSTED.equals(report.get("status"))) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted",
+ "Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be"
+ + " regenerated.");
+ }
+ }
+ jdbcTemplate.update("""
+ UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED'
+ WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED')
+ """, productId, officeId, postingDate);
+
+ final List> due = jdbcTemplate.queryForList("""
+ SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, d.cycle_cumulative_interest AS gross_interest,
+ sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name
+ FROM m_savings_daily_interest d
+ JOIN m_savings_account sa ON sa.id = d.savings_account_id
+ LEFT JOIN m_client c ON c.id = sa.client_id
+ LEFT JOIN m_group g ON g.id = sa.group_id
+ WHERE d.savings_product_id = ? AND d.posting_date <= ?
+ AND d.savings_transaction_id IS NULL
+ AND d.cycle_cumulative_interest > 0
+ AND sa.status_enum = ?
+ AND COALESCE(c.office_id, g.office_id) = ?
+ ORDER BY d.savings_account_id
+ """, productId, postingDate, ACTIVE, officeId);
+ if (due.isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due",
+ "No interest is due for this product in this branch on " + postingDate + ".");
+ }
+
+ // The GL accounts as the product maps them now; a product missing one is refused here, before
+ // anybody approves figures that could never be posted.
+ final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId);
+
+ // Round each customer's share, then make the rounded shares add up to the rounded pool: the
+ // residual goes to the largest balance, where a paisa is proportionally smallest.
+ BigDecimal grossTotal = BigDecimal.ZERO;
+ BigDecimal roundedTotal = BigDecimal.ZERO;
+ for (final Map row : due) {
+ final BigDecimal gross = (BigDecimal) row.get("gross_interest");
+ final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
+ row.put("rounded_interest", rounded);
+ grossTotal = grossTotal.add(gross);
+ roundedTotal = roundedTotal.add(rounded);
+ }
+ final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
+ final BigDecimal residual = pooled.subtract(roundedTotal);
+ if (residual.signum() != 0) {
+ final Map largest = due.stream().max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance")))
+ .orElseThrow();
+ largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
+ }
+
+ final Long reportId = jdbcTemplate.queryForObject("""
+ INSERT INTO m_savings_interest_posting_report
+ (savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id,
+ paid_gl_account_id, generation_mode, generated_by, generated_at)
+ VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ RETURNING id
+ """, Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId);
+
+ for (final Map row : due) {
+ final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING);
+ final BigDecimal interest = (BigDecimal) row.get("rounded_interest");
+ jdbcTemplate.update("""
+ INSERT INTO m_savings_interest_posting_report_line
+ (report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before,
+ balance_after, ledger_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest,
+ before, before.add(interest), row.get("ledger_id"));
+ }
+
+ log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId,
+ postingDate, mode, pooled, due.size());
+ return reportId;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java
index d2fc838bb..2082c2c46 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java
@@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.dailyinterest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
-import java.util.Comparator;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
@@ -29,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional;
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
* holds the figure that would say so.
*
- * This settles the debt the accrual has already recognised: the product's total moves out of the
- * liability the product names as its Savings Interest Payable, into the account it is paid out of,
- * and only then to the members:
+ * The product's total is raised as an expense and credited to the liability the product names as its
+ * Savings Interest Payable, moves from there into the clearing account it is paid out of, and only
+ * then to the members:
*
*
- * Dr Savings Interest Payable (liability, from the product)
- * Cr Savings Interest Paid on its way out
+ * Dr Interest On Savings (expense, from the product) cash-accounted products only
+ * Cr Savings Interest Payable (liability, from the product)
+ *
+ * Dr Savings Interest Payable
+ * Cr Savings Interest Paid (liability clearing) on its way out
*
* Dr Savings Interest Paid (per member)
* Cr Savings Control (per member, from the product) in the member's hands
*
*
- * The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
- * this is the settlement of what that recognition already booked - raising the expense again at
- * payout would put the same interest on the P&L twice.
+ * The payable and paid accounts each close the posting at nil, so the only lasting effect is the
+ * expense against the members' balances, and a liability report reading the payable is unaffected.
+ * The first pair is skipped for an accrual-accounted product, whose accruals already raised it.
*
* Every account named above is read from the product's own GL configuration; none is written into
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
@@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService {
* Accounting tab, which is where a bank picks the account interest is owed from.
*/
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
+ /** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */
+ private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3;
+ /** m_savings_product.accounting_type for cash-based accounting. */
+ private static final int ACCOUNTING_TYPE_CASH = 2;
private final JdbcTemplate jdbcTemplate;
+ /** The payable and paid accounts a product's interest passes through. */
+ public record PostingGlAccounts(Long payableGl, Long paidGl) {}
+
/**
- * Posts one product's interest for one posting date.
- *
- * @return the amount distributed, or zero if there was nothing to do
+ * The payable and paid accounts, as the product maps them now.
*
*
- * REQUIRED, not REQUIRES_NEW.
+ * The product's Accounting tab is the authority: the standard Interest Payable row first, then
+ * the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls
+ * back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and
+ * leaves the two-legged posting a product without these mappings has always had.
+ */
+ public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
+ // All three are required. Falling back to Savings Control when one was left blank posted the
+ // interest straight from expense to the member with no payable or paid leg at all, and nobody
+ // could tell from the entries that the product had simply lost its mappings.
+ mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
+ final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
+ namedGlAccount(productId, MAPPING_INTEREST_PAYABLE));
+ final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID);
+ if (payableGl == null || paidGl == null) {
+ final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
+ productId);
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '"
+ + productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid")
+ + " GL mapped. Set it under the product's Accounting and generate the report again.");
+ }
+ return new PostingGlAccounts(payableGl, paidGl);
+ }
+
+ /**
+ * Posts one approved interest posting report, paying each account exactly what was approved.
*
- * One product's failure must not roll back another's payment, and REQUIRED gives that already:
- * the orchestrator calls this outside any transaction of its own, so each product opens and
- * commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
- * running and waits for locks that transaction may hold. The accrual step immediately before
- * writes every account's ledger row, and this method reads and updates those same rows: with
- * REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
- * and the job hung with nothing in the log and its currently_running flag left set.
+ *
+ * A report belongs to one branch and one product, and every leg it raises is in that branch, so
+ * the branch's interest cost sits in its own P&L and its payable and paid accounts close at nil
+ * in its own books:
+ *
+ *
+ * Dr Interest On Savings (branch total) → Cr Savings Interest Payable
+ * Dr Savings Interest Payable → Cr Savings Interest Paid
+ * Dr Savings Interest Paid (once) → Cr each customer of the branch
+ *
+ *
+ *
+ * Nothing is recomputed. The report's lines are the authority: interest that accrued after the
+ * report was generated is not added, because the approver signed off on these figures and not on
+ * others. The payable and paid accounts are those recorded on the report when it was generated.
+ *
+ *
+ * Everything is dated the day it is actually posted — the business date — not the cycle's end: a
+ * branch that was shut on its posting date posts when it next closes, into a day that is still
+ * open, and the narration names the cycle the interest is for.
+ *
+ *
+ * The whole report settles in one transaction, so a failure leaves nobody half paid and the report
+ * still APPROVED for the next attempt.
+ *
+ * @return the amount distributed
*/
@Transactional
- public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
- final Long completed = jdbcTemplate.queryForObject("""
- SELECT COUNT(*) FROM m_savings_interest_posting_run
- WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
- """, Long.class, productId, postingDate);
- if (completed != null && completed > 0) {
- log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
- return BigDecimal.ZERO;
+ public BigDecimal postReport(final long reportId) {
+ final List> reports = jdbcTemplate.queryForList("""
+ SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id,
+ paid_gl_account_id
+ FROM m_savings_interest_posting_report
+ WHERE id = ?
+ FOR UPDATE
+ """, reportId);
+ if (reports.isEmpty()) {
+ throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist");
+ }
+ final Map report = reports.get(0);
+ if (!"APPROVED".equals(report.get("status"))) {
+ throw new IllegalStateException(
+ "Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
+ }
+ final long productId = ((Number) report.get("savings_product_id")).longValue();
+ final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
+ final LocalDate postedOn = DateUtils.getBusinessLocalDate();
+ final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
+
+ final List> lines = jdbcTemplate.queryForList("""
+ SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id,
+ sa.currency_code
+ FROM m_savings_interest_posting_report_line l
+ JOIN m_savings_account sa ON sa.id = l.savings_account_id
+ WHERE l.report_id = ?
+ ORDER BY l.savings_account_id
+ """, reportId);
+ if (lines.isEmpty()) {
+ throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay");
+ }
+ final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue()
+ : ((Number) lines.get(0).get("office_id")).longValue();
+
+ final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size());
+
+ final PostingGlAccounts current = resolvePostingGlAccounts(productId);
+ final Long payableGl = report.get("payable_gl_account_id") != null
+ ? ((Number) report.get("payable_gl_account_id")).longValue()
+ : current.payableGl();
+ final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
+ : current.paidGl();
+
+ final String currency = (String) lines.get(0).get("currency_code");
+ // One per report, and deterministic: a retried posting carries the same reference as the legs a
+ // failed attempt rolled back, and two branches posting the same product never share one.
+ final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId);
+ final String narration = "Savings interest for cycle ending " + cycleDate;
+
+ // Stage 0: the interest cost is raised and the total credited to the payable. Nothing else
+ // credits it on a cash-accounted product — the daily accrual writes no journal entry. An
+ // accrual-accounted product has already done this through its accrual postings.
+ if (isCashAccounted(productId)) {
+ final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
+ journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration);
+ journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration);
}
- // Every account whose posting date has arrived or passed and has not yet been paid.
- //
- // Read by posting_date rather than by the run date: the ledger keeps one row per account and
- // overwrites it daily, so on the day after a quarter end the row saying "this was a posting
- // day" no longer exists. Matching on the run date alone meant a quarter end the job did not
- // run on was never paid at all — the cycle simply rolled over with the interest carried into
- // it. A posting that is owed stays owed until it is made, which is how an overnight batch has
- // to behave when a night is missed.
- //
- // Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
- // somebody a second time.
- //
- // A savings account has no office of its own: it belongs to a client, or to a group when
- // there is no client, and the office is theirs. The journal entries have to be raised
- // against that office or they land in the wrong branch's books.
- final List> due = jdbcTemplate.queryForList("""
- SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
- d.cycle_cumulative_interest AS gross_interest,
- COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
- sa.currency_digits
- FROM m_savings_daily_interest d
- JOIN m_savings_account sa ON sa.id = d.savings_account_id
- LEFT JOIN m_client c ON c.id = sa.client_id
- LEFT JOIN m_group g ON g.id = sa.group_id
- WHERE d.savings_product_id = ? AND d.posting_date <= ?
- AND d.savings_transaction_id IS NULL
- AND d.cycle_cumulative_interest > 0
- AND COALESCE(c.office_id, g.office_id) IS NOT NULL
- ORDER BY d.savings_account_id
- """, productId, postingDate);
-
- if (due.isEmpty()) {
- log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
- return BigDecimal.ZERO;
- }
-
- final long runId = openRun(productId, postingDate, due.size());
-
- // Round each member's share, then make the rounded shares add up. The residual is the
- // difference between what the pool holds and what the members would otherwise receive.
- BigDecimal grossTotal = BigDecimal.ZERO;
- BigDecimal roundedTotal = BigDecimal.ZERO;
- for (final Map row : due) {
- final BigDecimal gross = (BigDecimal) row.get("gross_interest");
- final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
- row.put("rounded_interest", rounded);
- grossTotal = grossTotal.add(gross);
- roundedTotal = roundedTotal.add(rounded);
- }
- final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
- final BigDecimal residual = pooled.subtract(roundedTotal);
-
- if (residual.compareTo(BigDecimal.ZERO) != 0) {
- // To the largest balance: the account where a paisa is proportionally smallest.
- final Map largest = due.stream()
- .max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
- largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
- log.info("Savings interest posting: residual {} assigned to account {}", residual,
- largest.get("savings_account_id"));
- }
-
- // The three accounts the bill passes through on its way to the members. Every one is read
- // from the product's own configuration, so a bank on its own chart of accounts is posted to
- // the accounts it chose. No GL code is named in this class.
- //
- // The product's Accounting tab is the authority, and it is consulted first for all three:
- // Interest On Savings for the expense, and the standard Interest Payable row for the
- // payable. Those are the rows a bank actually fills in on the product screen.
- //
- // The reserved names in the product's other GL mappings are consulted only after that, as a
- // fallback for a product configured before Interest Payable was mapped, and for the paid leg,
- // which has no standard row of its own.
- //
- // Where a product sets none of them the bill still has somewhere to go: the payable falls
- // back to the product's Savings Control (interest owed is a liability either way), and the
- // paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
- // posting a product without these mappings has always had.
- final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
- namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
- mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
- final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
-
- final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
- final String currency = (String) due.get(0).get("currency_code");
- // Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
- // and the resumed legs have to carry the same reference as the legs already posted, or one
- // interest posting ends up split across two transaction ids.
- final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
- productId);
-
- // Stage 1: the debt the accrual has been building moves out of the payable and into the
- // account the payout is made from, so what is still merely owed and what is on its way to
- // the members are two separate balances rather than one.
- //
- // The interest expense itself is not raised here. It is recognised as the interest accrues,
- // day by day, and posting is the settlement of that recognised debt — raising the expense
- // again at payout would count the same interest twice on the P&L.
- //
- // Skipped where the product names no separate paid account: both legs then resolve to the
- // payable and this would debit and credit one account for the same amount, saying nothing.
- final boolean payableIsPaid = payableGl.equals(paidGl);
- final boolean stage1Applies = !payableIsPaid;
+ // Stage 1: the total moves out of the payable and into the account the payout is made from.
+ // Skipped where both are the same account, which would debit and credit it for nothing.
+ final boolean stage1Applies = !payableGl.equals(paidGl);
if (stage1Applies) {
- journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
- journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
+ journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration);
+ journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration);
}
-
- // Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
- // draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
- // from there instead — debiting the paid account would take the money out of an account
- // nothing put it into, leaving the payable standing as a debt that was in fact settled.
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
- // Stage 2: out to the members, one at a time.
+ // Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg
+ // carries the report's one transaction id, and every credit names its account and interest
+ // transaction, so who was paid what reads back from the journal alone.
+ journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration);
BigDecimal distributed = BigDecimal.ZERO;
- for (final Map row : due) {
- final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
- if (amount.compareTo(BigDecimal.ZERO) <= 0) {
- continue;
- }
- final long accountId = ((Number) row.get("savings_account_id")).longValue();
- final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
- // The member's own branch, which need not be the branch the pool was raised in.
- final long accountOfficeId = ((Number) row.get("office_id")).longValue();
-
- final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
- newBalance);
-
- journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
- accountId);
- creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
- accountId, transactionId);
-
- // closing_balance is raised here because the interest transaction has just been written
- // and the accrual for this day has already run: nothing else will fold it in before the
- // next day reads this row as its opening.
- jdbcTemplate.update("""
- UPDATE m_savings_daily_interest
- SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
- closing_balance = closing_balance + ?
- WHERE id = ?
- """, amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
-
- // Both interest columns move together, and that is not a duplicate.
- //
- // total_interest_earned_derived is the interest the account has earned to date;
- // total_interest_posted_derived is how much of that has been paid into the balance.
- // Screens and validate()'s own check read the unposted interest as earned - posted, so
- // raising only `posted` drives that difference negative: an account paid 26.52 with
- // `earned` still at zero reports its unposted interest as -26.52, and the account screen
- // showed exactly that. The accrual writes pending interest to the ledger and never to
- // `earned`, so this posting is the only place `earned` is realised.
- jdbcTemplate.update("""
- UPDATE m_savings_account
- SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
- total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
- total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
- WHERE id = ?
- """, amount, amount, amount, accountId);
-
- distributed = distributed.add(amount);
+ for (final Map line : lines) {
+ distributed = distributed
+ .add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId));
}
- // The whole point of the two control accounts: if this does not hold, nothing commits.
+ // If the credits do not add up to the debit, nothing commits.
if (pooled.compareTo(distributed) != 0) {
throw new IllegalStateException(String.format(
- "Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
- productId, postingDate, pooled, distributed));
+ "Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed));
}
+ return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size());
+ }
+
+ /**
+ * One customer's credit: the interest transaction, the Savings Control credit naming the account,
+ * and the account, ledger and report line brought up to date. The branch debit is raised by the
+ * caller.
+ *
+ * @return the amount credited, zero for a line with nothing to pay
+ */
+ private BigDecimal payMember(final Map line, final long productId, final long officeId,
+ final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference,
+ final String narration, final long runId) {
+ final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
+ if (amount.compareTo(BigDecimal.ZERO) <= 0) {
+ return BigDecimal.ZERO;
+ }
+ final long accountId = ((Number) line.get("savings_account_id")).longValue();
+
+ // A resumed or duplicated attempt must not pay anybody twice. The line itself records the
+ // payment, so this holds even where one account has two cycles posted on the same day.
+ if (line.get("savings_transaction_id") != null) {
+ throw new IllegalStateException("Savings account " + accountId + " was already paid by this report");
+ }
+
+ // The balance as it stands now, not as the report showed it: money moved since the report
+ // was generated, and the transaction's running balance has to follow the account.
+ final BigDecimal currentBalance = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
+ final BigDecimal newBalance = currentBalance.add(amount);
+
+ final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance);
+
+ final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
+ journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration);
+
+ // Settles the ledger row where it still holds the cycle the report was drawn from. Where the
+ // nightly run has already moved it into the next cycle — a branch that was shut on its posting
+ // date and posts days later — that row is the new cycle's and must not be marked paid; it only
+ // takes the money, because the next day's accrual opens from its closing balance.
+ final int settled = jdbcTemplate.update("""
+ UPDATE m_savings_daily_interest
+ SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
+ closing_balance = closing_balance + ?
+ WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ?
+ """, amount, transactionId, runId, amount, accountId, cycleDate);
+ if (settled == 0) {
+ jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?",
+ amount, accountId);
+ }
+
+ // Earned is only ever raised to what has now been posted, never added to: the engine may
+ // already have counted this interest as earned when the account was last recalculated,
+ // and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the
+ // unposted interest as earned - posted, so earned must not fall below posted either.
+ jdbcTemplate.update("""
+ UPDATE m_savings_account
+ SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
+ total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
+ total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
+ COALESCE(total_interest_posted_derived, 0) + ?)
+ WHERE id = ?
+ """, amount, amount, amount, accountId);
+
+ jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?",
+ transactionId, ((Number) line.get("line_id")).longValue());
+
+ return amount;
+ }
+
+ private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled,
+ final BigDecimal distributed, final long runId, final int accountCount) {
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
- SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
+ SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
WHERE id = ?
- """, pooled, distributed, residual, runId);
+ """, pooled, distributed, runId);
+ jdbcTemplate.update("""
+ UPDATE m_savings_interest_posting_report
+ SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
+ WHERE id = ?
+ """, runId, reportId);
- log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
- postingDate, distributed, due.size(), residual);
+ log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId,
+ cycleDate, distributed, accountCount);
return distributed;
}
- private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
+ /** One run per report. A retry of the same report reuses its run. */
+ private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate,
+ final int accountCount) {
jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_run
- (savings_product_id, posting_date, account_count, status)
- VALUES (?, ?, ?, 'PENDING')
- ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
+ (report_id, savings_product_id, office_id, posting_date, account_count, status)
+ VALUES (?, ?, ?, ?, ?, 'PENDING')
+ ON CONFLICT (report_id) DO UPDATE SET
account_count = EXCLUDED.account_count, status = 'PENDING',
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
- """, productId, postingDate, accountCount);
- return jdbcTemplate.queryForObject("""
- SELECT id FROM m_savings_interest_posting_run
- WHERE savings_product_id = ? AND posting_date = ?
- """, Long.class, productId, postingDate);
+ """, reportId, productId, officeId, cycleDate, accountCount);
+ return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId);
}
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
@@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService {
""", Long.class, accountId, date, INTEREST_POSTING);
}
- /** The member's own leg: the product's Savings Control account, credited per account. */
- private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
- final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
- final long transactionId) {
- final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
- journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
- }
-
- private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
- final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
- journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
- }
-
/**
* One journal entry leg.
*
@@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService {
*/
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
- final Long transactionId) {
+ final Long transactionId, final String narration) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
@@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService {
created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
- """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
- debit ? "Savings interest posting" : "Savings interest posted",
+ """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration,
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
systemUserId(), systemUserId(), date);
}
@@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService {
}
/** The first configured account of those given, in order of preference. */
+ private boolean isCashAccounted(final long productId) {
+ final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?",
+ Integer.class, productId);
+ return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH;
+ }
+
private static Long firstNonNull(final Long... candidates) {
for (final Long candidate : candidates) {
if (candidate != null) {
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java
new file mode 100644
index 000000000..070e2dbfb
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java
@@ -0,0 +1,191 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.savings.dailyinterest.api;
+
+import com.google.gson.Gson;
+import jakarta.ws.rs.Consumes;
+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.sql.Timestamp;
+import java.time.LocalDate;
+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.core.service.DateUtils;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.springframework.stereotype.Component;
+
+/**
+ * The savings interest posting reports, per branch and product: list, detail, generate, approve, the
+ * day-close check, and the head office's branch-wise summary.
+ *
+ *
+ * Posting is not offered here. A branch's approved reports are posted when that branch closes the
+ * day, so there is exactly one way money leaves the payable.
+ */
+@Path("/v1/savingsinterestpostingreports")
+@Component
+@RequiredArgsConstructor
+@Produces({ MediaType.APPLICATION_JSON })
+public class SavingsInterestPostingReportApiResource {
+
+ private static final String RESOURCE = "SAVINGSINTERESTREPORT";
+
+ private final SavingsInterestPostingReportService reportService;
+ private final PlatformSecurityContext context;
+ private final FromJsonHelper fromJsonHelper;
+ private final Gson gson = new Gson();
+
+ /** A branch user sees their own branch; a head office user every branch, or one with officeId. */
+ @GET
+ public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
+ @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) {
+ final AppUser user = context.authenticatedUser();
+ user.validateHasReadPermission(RESOURCE);
+ return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList());
+ }
+
+ /** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
+ @GET
+ @Path("/due-without-report")
+ public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) {
+ final AppUser user = context.authenticatedUser();
+ user.validateHasReadPermission(RESOURCE);
+ final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
+ return gson.toJson(reportService.dueWithoutReport(date, user));
+ }
+
+ /**
+ * What stops a branch closing the business day: its own reports due and not yet approved. The
+ * day-close screen reads it to warn before the close is attempted.
+ */
+ @GET
+ @Path("/day-close-check")
+ public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
+ final AppUser user = context.authenticatedUser();
+ final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
+ final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
+ final List blocking = reportService.reportsBlockingDayClose(officeId, businessDate);
+ final Map result = new LinkedHashMap<>();
+ result.put("businessDate", businessDate.toString());
+ result.put("canClose", blocking.isEmpty());
+ result.put("pendingProducts", blocking);
+ return gson.toJson(result);
+ }
+
+ /** The head office's view: per branch and product, customers paid and interest, over a date range. */
+ @GET
+ @Path("/summary")
+ public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
+ @QueryParam("productId") final Long productId) {
+ final AppUser user = context.authenticatedUser();
+ user.validateHasReadPermission(RESOURCE);
+ if (!reportService.isHeadOffice(user.getOffice().getId())) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only",
+ "The branch-wise interest summary is for the head office.");
+ }
+ return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
+ }
+
+ @GET
+ @Path("/{reportId}")
+ public String detail(@PathParam("reportId") final Long reportId) {
+ context.authenticatedUser().validateHasReadPermission(RESOURCE);
+ final Map report = plain(reportService.detail(reportId));
+ @SuppressWarnings("unchecked")
+ final List> lines = (List>) report.get("lines");
+ report.put("lines", lines.stream().map(this::plain).toList());
+ return gson.toJson(report);
+ }
+
+ /** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
+ @POST
+ @Consumes({ MediaType.APPLICATION_JSON })
+ public String command(@QueryParam("command") final String command, final String body) {
+ if (!"regenerate".equalsIgnoreCase(command)) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
+ "Unsupported command: " + command);
+ }
+ final AppUser user = context.authenticatedUser();
+ user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
+ final var root = fromJsonHelper.parse(body);
+ final Long productId = fromJsonHelper.extractLongNamed("productId", root);
+ final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root);
+ final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root);
+ if (productId == null) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?");
+ }
+ final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
+ final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
+ final Long reportId = reportService.regenerate(productId, officeId, date, user);
+ return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED));
+ }
+
+ /** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */
+ @POST
+ @Path("/{reportId}")
+ @Consumes({ MediaType.APPLICATION_JSON })
+ public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
+ if (!"approve".equalsIgnoreCase(command)) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
+ "Unsupported command: " + command);
+ }
+ final AppUser user = context.authenticatedUser();
+ user.validateHasPermissionTo("APPROVE_" + RESOURCE);
+ reportService.approve(reportId, user);
+ return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED));
+ }
+
+ private static LocalDate parseDate(final String raw) {
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ try {
+ return LocalDate.parse(raw.trim());
+ } catch (final RuntimeException e) {
+ throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date",
+ "Dates are expected as yyyy-MM-dd, not " + raw);
+ }
+ }
+
+ /** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
+ private Map plain(final Map row) {
+ final Map out = new LinkedHashMap<>();
+ row.forEach((key, value) -> {
+ if (value instanceof java.sql.Date d) {
+ out.put(key, d.toLocalDate().toString());
+ } else if (value instanceof Timestamp t) {
+ out.put(key, t.toLocalDateTime().toString());
+ } else {
+ out.put(key, value);
+ }
+ });
+ return out;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java
new file mode 100644
index 000000000..134bafe04
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java
@@ -0,0 +1,53 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
+
+import org.apache.fineract.infrastructure.jobs.service.JobName;
+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 SavingsInterestPostingReportConfig {
+
+ @Autowired
+ private JobRepository jobRepository;
+ @Autowired
+ private PlatformTransactionManager transactionManager;
+
+ @Bean
+ protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
+ return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
+ .tasklet(savingsInterestPostingReportTasklet, transactionManager).build();
+ }
+
+ @Bean
+ public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
+ return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
+ .start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer())
+ .build();
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java
new file mode 100644
index 000000000..287b00e6d
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java
@@ -0,0 +1,45 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
+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;
+import org.springframework.stereotype.Component;
+
+/**
+ * Noon on the business date: generates the interest posting report for every savings product whose
+ * interest falls due, so it can be reviewed and approved before the branches close the day.
+ */
+@RequiredArgsConstructor
+@Component
+public class SavingsInterestPostingReportTasklet implements Tasklet {
+
+ private final SavingsInterestPostingReportService reportService;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
+ reportService.generateDue(DateUtils.getBusinessLocalDate());
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java
index 008486db0..b6c0ce636 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java
@@ -436,8 +436,14 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
public List retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
final Integer status, final Long maxSavingsId) {
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
+ // A product on the daily-interest timetable is paid by the pooled posting instead
+ // (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
+ // FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
+ // subquery so a page is never cut short by accounts that are then dropped.
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
- + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id ";
+ + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
+ + "and not exists (select 1 from m_savings_interest_posting_schedule s "
+ + "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
if (backdatedTxnsAllowedTill) {
sql = sql
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties
index b6ac7ca48..020cdbd2f 100644
--- a/fineract-provider/src/main/resources/application.properties
+++ b/fineract-provider/src/main/resources/application.properties
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
-fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
+fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
-spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
+spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
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 9b2f6e419..3d875a303 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
@@ -144,6 +144,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_I_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_III_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VI_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VII_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml
new file mode 100644
index 000000000..9e5c2a7c5
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml
@@ -0,0 +1,908 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 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', 'ASSET', 'MEMO'));
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE role_group = 'FORM9'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L4_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L5_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A4_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A6_%'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml
new file mode 100644
index 000000000..916a4bc4b
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_%'
+
+
+
+
+ -- 1(a) Paid up capital - Individuals. Both share accounts: in a co-operative bank the
+ -- linking share and the ordinary share together ARE the members' paid up capital.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L1_A_CAPITAL_INDIVIDUALS', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('300101', '300102');
+
+ -- 2(a) Statutory Reserves.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L2_A_STATUTORY_RESERVE', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '300902';
+
+ -- 2(c) Dividend Equalisation Fund.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L2_C_DIVIDEND_EQUALISATION', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '300901';
+
+ -- 2(e) Bad and Doubtful Debt Reserve. The NPA provision AND the standard-asset
+ -- provision, because the form's line is the whole reserve held against advances - not
+ -- the bad-debt account alone. On the filed return this was drawn from, mapping only one
+ -- of them would have understated the line by three quarters.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('500501', '500502');
+
+ -- 2(g) Other Funds and Reserves - every other fund the bank carries.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '200905';
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L4_%'
+
+
+
+
+ -- 4(a)(iv) Current deposits from individuals, firms and associations.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L4_A4_CURRENT_INDIVIDUALS', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '200102';
+
+ -- 4(b)(iv) Savings deposits, DEMAND share.
+ --
+ -- Split 11/89 against 5(b)(iv), which is the split the filed return this was drawn from
+ -- carries. It is NOT the 20/80 Form 1 uses: that return charges its ratios on a
+ -- differently drawn base, and the two splits are settled separately with the bank's
+ -- auditors. The two shares here must always total 100, or the return reports less in
+ -- savings than the bank holds.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', id, 11, now() FROM acc_gl_account
+ WHERE gl_code = '200101';
+
+ -- 4(d) Other Demand Liabilities - everything owed on demand that is not a deposit.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L4_D_OTHER_DEMAND', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('200203', '200204', '200401', '200402', '200501', '200502', '200503',
+ '200504', '200505', '200506', '200901', '200902', '200903', '200904',
+ '898966');
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L5_%'
+
+
+
+
+ -- 5(a)(iv) Fixed deposits from individuals, firms and associations.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L5_A4_FD_INDIVIDUALS', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '200103';
+
+ -- 5(b)(iv) Savings deposits, TIME share - the remainder of the account.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', id, 89, now() FROM acc_gl_account
+ WHERE gl_code = '200101';
+
+ -- 5(d) Cash certificates and recurring deposits.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L5_D_CASH_CERT_RD', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '200201';
+
+ -- 5(e) Other Time Liabilities - interest accrued on deposits but not yet paid.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L5_E_OTHER_TIME', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('200202', '200301', '200302', '200303');
+
+ -- 9. Balance of Profit.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L9_BALANCE_OF_PROFIT', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('300903', 'BANKPROFIT01');
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_A%'
+
+
+
+
+ -- 1. Cash in Hand - vault and teller balances.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A1_CASH_IN_HAND', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('100101', '100102');
+
+ -- 2(a) Balance with the Reserve Bank of India.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A2_A_CUR_RBI', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '100203';
+
+ -- 2(c) Balance with the Banking System - the commercial banks the bank keeps operating
+ -- accounts with. Co-operative banks have their own lines, which this chart has no
+ -- accounts for.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A2_C_CUR_BANKING_SYSTEM', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('100201', '100202');
+
+ -- 6(f)(i) Short term advances for other purposes - cash credit, loans against deposits
+ -- and warehouse receipt finance.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A6_F_OTHER_SHORT', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('100301', '100302', '100303');
+
+ -- 6(f)(ii) Medium term advances - personal loans.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A6_F_OTHER_MEDIUM', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '100402';
+
+ -- 6(f)(iii) Long term advances - loans against property and house loans.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A6_F_OTHER_LONG', id, 100, now() FROM acc_gl_account
+ WHERE gl_code IN ('100401', '100501');
+
+ -- 8. Interest Receivable.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A8_INTEREST_RECEIVABLE', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '100601';
+
+ -- 11(i) Other Assets, tangible - advance tax and tax deducted at source recoverable.
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_A11_I_TANGIBLE_ASSETS', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = '100602';
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml
new file mode 100644
index 000000000..5d2997caf
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_rbi_reserve_gl_config c
+ JOIN acc_gl_account a ON a.id = c.gl_account_id
+ WHERE a.gl_code = 'DIVROUNDOFF01'
+
+
+
+
+ INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
+ SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account
+ WHERE gl_code = 'DIVROUNDOFF01';
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml
new file mode 100644
index 000000000..1e42440c8
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM job WHERE name = 'Generate Savings Interest Posting Report'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_SAVINGSINTERESTREPORT',
+ 'REGENERATE_SAVINGSINTERESTREPORT', 'APPROVE_SAVINGSINTERESTREPORT')
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_role_permission rp
+ JOIN m_permission p ON p.id = rp.permission_id
+ WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT',
+ 'APPROVE_SAVINGSINTERESTREPORT')
+
+
+
+
+ INSERT INTO m_role_permission (role_id, permission_id)
+ SELECT r.id, p.id
+ FROM m_role r
+ CROSS JOIN m_permission p
+ WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT',
+ 'APPROVE_SAVINGSINTERESTREPORT')
+ AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
+ AND NOT EXISTS (
+ SELECT 1 FROM m_role_permission rp
+ WHERE rp.role_id = r.id AND rp.permission_id = p.id
+ )
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml
new file mode 100644
index 000000000..7695a23d6
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UPDATE m_savings_interest_posting_report r
+ SET office_id = (SELECT MIN(l.office_id) FROM m_savings_interest_posting_report_line l WHERE l.report_id = r.id)
+ WHERE r.office_id IS NULL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+