From 56d98bac8d4868576ff0cf726510317c7237fa3d Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 1 Oct 2026 13:07:58 +0530 Subject: [PATCH] feat(rbi-reserve,savings): add Form1/Form9 returns, interest posting report and branch-day enhancements --- .../infrastructure/jobs/service/JobName.java | 1 + .../validation/GLAccountTransactionGuard.java | 45 + .../service/AccountingProcessorHelper.java | 97 +- ...ualBasedAccountingProcessorForSavings.java | 17 +- .../api/BranchDayRegisterApiResource.java | 54 + ...lerkShareWithdrawalRequestApiResource.java | 42 + .../api/RbiFormAFilingApiResource.java | 59 +- .../api/RbiReserveConfigApiResource.java | 41 +- .../api/RbiReserveReturnApiResource.java | 1510 ++++++++++++++++- .../service/RbiFormAAutoSaveService.java | 72 +- .../SavingsDailyInterestOrchestrator.java | 151 +- .../SavingsDailyInterestService.java | 28 +- .../SavingsInterestPostingReportService.java | 578 +++++++ .../SavingsPooledInterestPostingService.java | 456 ++--- ...vingsInterestPostingReportApiResource.java | 191 +++ .../SavingsInterestPostingReportConfig.java | 53 + .../SavingsInterestPostingReportTasklet.java | 45 + ...SavingsAccountReadPlatformServiceImpl.java | 8 +- .../src/main/resources/application.properties | 4 +- .../tenant/final-changelog-tenant.xml | 6 + .../parts/0585_form1_statutory_heads.xml | 240 +++ .../tenant/parts/0586_form9_return_heads.xml | 908 ++++++++++ .../parts/0587_form9_default_mappings.xml | 212 +++ .../0588_form9_map_dividend_round_off.xml | 58 + .../0589_savings_interest_posting_report.xml | 204 +++ ...ings_interest_posting_report_by_branch.xml | 80 + 26 files changed, 4793 insertions(+), 367 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_form1_statutory_heads.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml 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: + * + *

+ * + *

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

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