From 1a914a3de088ac73255890607d0915b1ff093bd8 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 8 Oct 2026 19:06:40 +0530 Subject: [PATCH] feat(fixed-deposits): maturity approval report, scoped to deposits with no closure instruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the FD maturity approval workflow: a deposit with no closure instruction of its own (Maturity Instructions left "Not selected") waits — status, final interest and whatever renewal is later chosen from the FD Dashboard all run together in one real maturity run, only once a second user approves the report, as the branch closes its day. A deposit that already named Transfer to Savings or a Reinvest option settles itself the moment it matures, unchanged — a decision the customer already made in writing needs no second approval. The nightly job is narrowed to skip only the undecided ones; FixedDepositAccount#previewMaturityInterestPeriods reads the same maturity-interest calculation the real posting uses, without writing anything down, so the report's figure is never a guess. Posting an approved report calls the existing, unchanged DepositAccountWritePlatformService#updateMaturityDetails per deposit, each in its own transaction — one with a bad closure target does not hold up the others, and a retry only touches what did not post last time. Adds a reconciliation view (outside-approval) listing deposits that matured via their own closure instruction, same reasoning as the interest report's: found by what has no report line, not by naming the instruction, so a future direct path is caught here too. Day-close integration mirrors the interest report exactly: blocks on a pending maturity report, posts approved ones alongside approved interest reports, independently so one failing does not stop the other. Co-Authored-By: Claude Sonnet 5 --- .../api/GlobalConfigurationConstants.java | 1 + .../domain/ConfigurationDomainService.java | 7 + .../infrastructure/jobs/service/JobName.java | 1 + .../domain/ConfigurationDomainServiceJpa.java | 5 + .../api/BranchDayRegisterApiResource.java | 39 + .../savings/domain/FixedDepositAccount.java | 32 + .../FixedDepositMaturityOrchestrator.java | 163 +++++ .../FixedDepositMaturityReportService.java | 691 ++++++++++++++++++ ...FixedDepositMaturityReportApiResource.java | 198 +++++ .../FixedDepositMaturityReportConfig.java | 52 ++ .../FixedDepositMaturityReportTasklet.java | 57 ++ ...eDepositsAccountMaturityDetailsConfig.java | 9 +- ...DepositsAccountMaturityDetailsTasklet.java | 22 + .../tenant/final-changelog-tenant.xml | 1 + ..._fd_maturity_interest_posting_approval.xml | 298 ++++++++ 15 files changed, 1575 insertions(+), 1 deletion(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityOrchestrator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityReportService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/api/FixedDepositMaturityReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportTasklet.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_fd_maturity_interest_posting_approval.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java index 41cd2bf0b..d8ae49255 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java @@ -33,6 +33,7 @@ public final class GlobalConfigurationConstants { public static final String GRACE_ON_PENALTY_POSTING = "grace-on-penalty-posting"; public static final String SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END = "savings-interest-posting-current-period-end"; public static final String FD_INTEREST_POSTING_APPROVAL = "fd-interest-posting-approval"; + public static final String FD_MATURITY_INTEREST_POSTING_APPROVAL = "fd-maturity-interest-posting-approval"; public static final String FINANCIAL_YEAR_BEGINNING_MONTH = "financial-year-beginning-month"; public static final String MIN_CLIENTS_IN_GROUP = "min-clients-in-group"; public static final String MAX_CLIENTS_IN_GROUP = "max-clients-in-group"; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java index fb0c03c76..4141516ed 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java @@ -75,6 +75,13 @@ public interface ConfigurationDomainService { */ boolean isFixedDepositInterestPostingApprovalEnabled(); + /** + * Whether a fixed deposit maturing is itself reported and approved before its final interest posts + * and its closure (or renewal) instructions run, instead of all three happening directly, the + * moment it matures. + */ + boolean isFixedDepositMaturityInterestPostingApprovalEnabled(); + Integer retrieveFinancialYearBeginningMonth(); Integer retrieveMinAllowedClientsInGroup(); 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 b442ca1af..7828413bb 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 @@ -45,6 +45,7 @@ public enum JobName { SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), // FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), // + FD_MATURITY_REPORT("Generate Fixed Deposit Maturity 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/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java index 2b5f99d3b..5aa32f27e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java @@ -201,6 +201,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_INTEREST_POSTING_APPROVAL).isEnabled(); } + @Override + public boolean isFixedDepositMaturityInterestPostingApprovalEnabled() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_MATURITY_INTEREST_POSTING_APPROVAL).isEnabled(); + } + @Override public Integer retrieveFinancialYearBeginningMonth() { final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( 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 79720f616..c2e3f4018 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 @@ -50,6 +50,8 @@ import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestO import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator; import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService; +import org.apache.fineract.portfolio.savings.fdmaturity.FixedDepositMaturityOrchestrator; +import org.apache.fineract.portfolio.savings.fdmaturity.FixedDepositMaturityReportService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -95,6 +97,8 @@ public class BranchDayRegisterApiResource { private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; private final FixedDepositInterestPostingReportService fdInterestPostingReportService; private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator; + private final FixedDepositMaturityReportService fdMaturityReportService; + private final FixedDepositMaturityOrchestrator fdMaturityOrchestrator; private final Gson gson = new Gson(); // ── GET /v1/branchday ── the register itself ────────────────────────────── @@ -245,6 +249,11 @@ public class BranchDayRegisterApiResource { final Map fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate); result.put("fdInterestPending", fdInterest.get("pending")); result.put("fdInterestDue", fdInterest.get("due")); + // Fixed deposits maturing today — the same read, now for the event of maturing rather than a + // periodic posting, blocking the close on the same grounds. + final Map fdMaturity = fdMaturityReportService.dayCloseMaturityView(officeId, businessDate); + result.put("fdMaturityPending", fdMaturity.get("pending")); + result.put("fdMaturityDue", fdMaturity.get("due")); return gson.toJson(result); } @@ -635,6 +644,17 @@ public class BranchDayRegisterApiResource { + ". Approve it under Reports → FD Interest Posting Reports, then close the day."); } + // ── And its fixed deposit maturity reports, on the same grounds ── + // Only while maturities are on the approval path; with that switched off the nightly job + // settles a matured deposit directly and there is nothing here to approve. + final List fdMaturityPending = fdMaturityReportService.reportsBlockingDayClose(officeId, businessDate); + if (!fdMaturityPending.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.fd.maturity.report.pending", + "Cannot close the day — this branch's fixed deposit maturity report is not approved for: " + + String.join(", ", fdMaturityPending) + + ". Approve it under Reports → FD Maturity 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. @@ -787,6 +807,25 @@ public class BranchDayRegisterApiResource { } } + // ── And the branch's approved fixed deposit maturities ── + // Each approved deposit's status, interest and closure all run together here, in the real + // DepositAccountWritePlatformService#updateMaturityDetails — this only decides that the moment + // has come. + final List fdMaturityToPost = new ArrayList<>(fdMaturityReportService.approvedReportIds(officeId, businessDate)); + if (isHeadOffice) { + fdMaturityToPost.addAll(fdMaturityReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId)); + } + if (!fdMaturityToPost.isEmpty()) { + log.info("Day close {}: office {} posting {} approved FD maturity report(s)", businessDate, officeId, + fdMaturityToPost.size()); + final List fdMaturityFailures = fdMaturityOrchestrator.postReports(fdMaturityToPost); + if (!fdMaturityFailures.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.fd.maturity.posting.failed", + "Cannot close the day — fixed deposit maturities could not be posted: " + String.join("; ", fdMaturityFailures) + + ". 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/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 5226ede94..58f928283 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -719,6 +719,38 @@ public class FixedDepositAccount extends SavingsAccount { this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); } + /** + * What {@link #postMaturityInterest} would credit and has not been paid yet — worked out the exact + * same way, down to the same period-already-paid check, but nothing is added to the account. A + * maturity interest report asks this before anyone has approved anything, so the account must come + * out of the call exactly as it went in; the three-way correction branch {@code postMaturityInterest} + * takes for a stale posted amount has no analogue here; a period already posted even if the figure + * since changed is simply not unpaid. + */ + public List previewMaturityInterestPeriods(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, + final Integer financialYearBeginningMonth) { + final LocalDate interestPostingUpToDate = maturityDate(); + final MathContext mc = MathContext.DECIMAL64; + final boolean isInterestTransfer = false; + final LocalDate postInterestOnDate = null; + final boolean backdatedTxnsAllowedTill = false; + final boolean postReversals = false; + final List postingPeriods = calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, + postReversals); + + final List unpaid = new ArrayList<>(); + for (final PostingPeriod period : postingPeriods) { + LocalDate transactionDate = period.dateOfPostingTransaction(); + transactionDate = DateUtils.isAfter(transactionDate, interestPostingUpToDate) ? interestPostingUpToDate : transactionDate; + final SavingsAccountTransaction existing = findInterestPostingTransactionFor(transactionDate); + if (existing == null || !interestAlreadyPaidOut(existing)) { + unpaid.add(period); + } + } + return unpaid; + } + /** * Whether this period's posted interest has already left the account for the linked savings account. *

diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityOrchestrator.java new file mode 100644 index 000000000..bf4ea896c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityOrchestrator.java @@ -0,0 +1,163 @@ +/** + * 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.fdmaturity; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +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; + +/** + * Posts approved fixed deposit maturity reports. + * + *

+ * Posting one deposit's maturity is calling the real, unchanged + * {@link DepositAccountWritePlatformService#updateMaturityDetails} — status, interest and closure all + * run together exactly as they always have. This orchestrator's own job is only deciding when: each + * deposit runs in a transaction of its own, so one with a bad closure target does not undo the others + * that matured cleanly, and a line already posted (by an earlier, partly-failed attempt) is skipped — + * retrying a report only touches what did not go through last time. A report with any deposit still + * unposted stays APPROVED rather than POSTED, so closing again retries exactly those. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class FixedDepositMaturityOrchestrator { + + private static final String APPROVED = "APPROVED"; + + private final JdbcTemplate jdbcTemplate; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final FixedDepositMaturityReportService reportService; + private final PlatformTransactionManager transactionManager; + + /** + * Posts each report's still-unposted deposits. + * + * @return one message per report left with a deposit that could not mature, empty where every + * report fully posted + */ + public List postReports(final List reportIds) { + final TransactionTemplate newTransaction = new TransactionTemplate(this.transactionManager); + newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + final List failures = new ArrayList<>(); + for (final Long reportId : reportIds) { + final String failure = postReport(reportId, newTransaction); + if (failure != null) { + failures.add(failure); + } + } + return failures; + } + + /** @return a message for this report if it has a deposit left unposted, null where it fully posted */ + private String postReport(final Long reportId, final TransactionTemplate newTransaction) { + final Map report = this.jdbcTemplate.queryForMap( + "SELECT office_id, posting_date, status FROM m_fd_maturity_report WHERE id = ?", reportId); + if (!APPROVED.equals(report.get("status"))) { + log.warn("FD maturity: report {} is {}, not APPROVED — skipped", reportId, report.get("status")); + return null; + } + final long officeId = ((Number) report.get("office_id")).longValue(); + final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + + final List> lines = this.jdbcTemplate.queryForList(""" + SELECT id, fd_account_id, account_no FROM m_fd_maturity_report_line WHERE report_id = ? AND posted_at IS NULL + """, reportId); + + final long runId = newTransaction.execute(status -> openRun(reportId, officeId, postingDate, lines.size())); + + int posted = 0; + final List lineFailures = new ArrayList<>(); + for (final Map line : lines) { + final long lineId = ((Number) line.get("id")).longValue(); + final long accountId = ((Number) line.get("fd_account_id")).longValue(); + final String accountNo = (String) line.get("account_no"); + try { + newTransaction.executeWithoutResult(status -> { + this.depositAccountWritePlatformService.updateMaturityDetails(accountId, DepositAccountType.FIXED_DEPOSIT); + this.jdbcTemplate.update( + "UPDATE m_fd_maturity_report_line SET posted_at = CURRENT_TIMESTAMP, failure_reason = NULL WHERE id = ?", + lineId); + }); + posted++; + } catch (final RuntimeException e) { + log.error("FD maturity: deposit {} (line {} of report {}) could not mature", accountNo, lineId, reportId, e); + final String reason = truncate(e.getMessage()); + newTransaction.executeWithoutResult( + status -> this.jdbcTemplate.update("UPDATE m_fd_maturity_report_line SET failure_reason = ? WHERE id = ?", + reason, lineId)); + lineFailures.add(accountNo + ": " + reason); + } + } + + final int posted2 = posted; + if (lineFailures.isEmpty()) { + newTransaction.executeWithoutResult(status -> completeRun(runId, posted2, "COMPLETED", null)); + newTransaction.executeWithoutResult(status -> this.jdbcTemplate.update(""" + UPDATE m_fd_maturity_report SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, + failure_reason = NULL + WHERE id = ? + """, runId, reportId)); + log.info("FD maturity: report {} (branch {}, {}) matured {} deposit(s)", reportId, officeId, postingDate, posted); + return null; + } + + final String summary = String.join("; ", lineFailures); + newTransaction.executeWithoutResult(status -> completeRun(runId, posted2, "FAILED", summary)); + this.reportService.recordPostingFailure(reportId, summary); + return "report " + reportId + " (branch " + officeId + "): " + lineFailures.size() + " of " + lines.size() + + " deposit(s) could not mature — " + summary; + } + + private long openRun(final long reportId, final long officeId, final LocalDate postingDate, final int accountCount) { + this.jdbcTemplate.update(""" + INSERT INTO m_fd_maturity_run (report_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 + """, reportId, officeId, postingDate, accountCount); + return this.jdbcTemplate.queryForObject("SELECT id FROM m_fd_maturity_run WHERE report_id = ?", Long.class, reportId); + } + + private void completeRun(final long runId, final int postedCount, final String status, final String failureReason) { + this.jdbcTemplate.update(""" + UPDATE m_fd_maturity_run + SET posted_count = ?, status = ?, failure_reason = ?, completed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, postedCount, status, failureReason, runId); + } + + private static String truncate(final String reason) { + if (reason == null) { + return "unknown failure"; + } + return reason.length() > 1000 ? reason.substring(0, 1000) : reason; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityReportService.java new file mode 100644 index 000000000..e1aa10ca6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityReportService.java @@ -0,0 +1,691 @@ +/** + * 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.fdmaturity; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +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.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.apache.fineract.portfolio.savings.domain.DepositAccountAssembler; +import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; +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 fixed deposit maturity report: every deposit of one branch maturing on a date with no closure + * instruction of its own, what its last posting would still come to, put in front of the branch before + * any of that happens. + * + *

+ * Only a deposit with nothing decided for it needs this: one that already named Transfer to Savings or + * a Reinvest option on its own closure instruction settles itself the moment it matures, exactly as it + * always has — the approval question is for a decision nobody has made yet, not one the customer + * already made in writing when the account was opened. See {@code autoMatured} for visibility into + * those. For the undecided ones, this report puts the same two-eyes gate 0593 put in front of periodic + * interest in front of the maturity event itself: one user generates it, a different user approves it, + * and only on approval does the real maturity job — + * {@link org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService#updateMaturityDetails} + * — actually run for that account, as the branch closes its day. Until then the account stays ACTIVE; + * status, interest and whatever renewal is later chosen from the FD Dashboard all change together in + * that one real run, never in stages. + * + *

+ * The estimated interest a line shows is read with + * {@link FixedDepositAccount#previewMaturityInterestPeriods}, the exact same calculation the real + * posting uses, just without writing anything down — so a figure approved here is never different + * from what the real run later credits unless the account's own transactions changed in between, the + * same risk the periodic report already lives with. + */ +@Service +@Slf4j +public class FixedDepositMaturityReportService { + + 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; + private static final int ACTIVE_STATUS = 300; + private static final int DEPOSIT_TYPE_FIXED = 200; + + private static final String CLOSURE_LABEL_CASE = """ + CASE COALESCE(datp.on_account_closure_enum, 0) + WHEN 100 THEN 'WITHDRAW_DEPOSIT' WHEN 200 THEN 'TRANSFER_TO_SAVINGS' + WHEN 300 THEN 'REINVEST_PRINCIPAL_AND_INTEREST' WHEN 400 THEN 'REINVEST_PRINCIPAL_ONLY' + WHEN 500 THEN 'PARTIAL_REINVEST' WHEN 600 THEN 'TRANSFER_TO_GL' ELSE 'NONE' + END + """; + + private final JdbcTemplate jdbcTemplate; + private final DepositAccountAssembler depositAccountAssembler; + private final ConfigurationDomainService configurationDomainService; + /** Borrowed for the branch rules, same reason the interest report borrows it. */ + private final SavingsInterestPostingReportService branchAuthority; + private final TransactionTemplate transactionTemplate; + /** + * Open-session-in-view is off (spring.jpa.open-in-view=false), so loading the account and reading + * its lazy associations has to happen inside one transaction of its own — a read called from a plain + * GET would otherwise load the account, close the session, and throw the moment + * calculateInterestUsing touched anything not already fetched. Read-only, which both documents the + * intent and is itself a second guarantee, alongside previewMaturityInterestPeriods's own, that + * nothing here is ever written down. + */ + private final TransactionTemplate readOnlyTransaction; + + public FixedDepositMaturityReportService(final JdbcTemplate jdbcTemplate, final DepositAccountAssembler depositAccountAssembler, + final ConfigurationDomainService configurationDomainService, final SavingsInterestPostingReportService branchAuthority, + final PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.depositAccountAssembler = depositAccountAssembler; + this.configurationDomainService = configurationDomainService; + this.branchAuthority = branchAuthority; + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + this.readOnlyTransaction = new TransactionTemplate(transactionManager); + this.readOnlyTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + this.readOnlyTransaction.setReadOnly(true); + } + + /** One deposit maturing on the report's date, priced but not yet posted. */ + public record DueLine(Long accountId, String accountNo, String clientName, Long officeId, Long productId, String productName, + String fdType, LocalDate maturityDate, BigDecimal depositAmount, BigDecimal interestRate, BigDecimal estimatedInterest, + String closureInstruction, String closureAccountNo) {} + + // ── computing what is due ──────────────────────────────────────────────────────────────────── + + /** + * Every active fixed deposit of the branch (or every branch, with officeId null) whose maturity + * date has arrived by the posting date and is not already on a live report for it. + */ + public List computeDue(final LocalDate postingDate, final Long officeId) { + final StringBuilder sql = new StringBuilder(""" + SELECT sa.id AS account_id, sa.account_no, sa.product_id, sp.name AS product_name, sp.fd_type, + COALESCE(c.display_name, g.display_name) AS client_name, + COALESCE(c.office_id, g.office_id) AS office_id, + sa.nominal_annual_interest_rate AS interest_rate, + datp.deposit_amount, datp.maturity_date, datp.transfer_to_savings_account_id, + ls.account_no AS closure_account_no, + """); + sql.append(CLOSURE_LABEL_CASE).append(" AS closure_instruction\n"); + sql.append(""" + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + LEFT JOIN m_savings_account ls ON ls.id = datp.transfer_to_savings_account_id + WHERE sa.status_enum = ? AND sa.deposit_type_enum = ? AND datp.maturity_date <= ? + AND COALESCE(datp.on_account_closure_enum, 0) = 0 + AND NOT EXISTS (SELECT 1 FROM m_fd_maturity_report_line l + JOIN m_fd_maturity_report r ON r.id = l.report_id + WHERE l.fd_account_id = sa.id AND r.status <> 'SUPERSEDED') + """); + final List params = new ArrayList<>(List.of(ACTIVE_STATUS, DEPOSIT_TYPE_FIXED, postingDate)); + if (officeId != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(officeId); + } + sql.append(" ORDER BY sa.account_no"); + + final List> rows = this.jdbcTemplate.query(sql.toString(), (rs, rowNum) -> { + final Map row = new LinkedHashMap<>(); + row.put("account_id", rs.getLong("account_id")); + row.put("account_no", rs.getString("account_no")); + row.put("product_id", rs.getLong("product_id")); + row.put("product_name", rs.getString("product_name")); + row.put("fd_type", rs.getString("fd_type")); + row.put("client_name", rs.getString("client_name")); + row.put("office_id", (Long) (rs.getObject("office_id") == null ? null : rs.getLong("office_id"))); + row.put("interest_rate", rs.getBigDecimal("interest_rate")); + row.put("deposit_amount", rs.getBigDecimal("deposit_amount")); + row.put("maturity_date", rs.getDate("maturity_date")); + row.put("closure_account_no", rs.getString("closure_account_no")); + row.put("closure_instruction", rs.getString("closure_instruction")); + return row; + }, params.toArray()); + + final List due = new ArrayList<>(); + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + for (final Map row : rows) { + final Long officeIdOf = (Long) row.get("office_id"); + if (officeIdOf == null) { + log.warn("FD maturity report: deposit {} has no branch and is left out", row.get("account_id")); + continue; + } + final BigDecimal estimated = previewInterest(((Number) row.get("account_id")).longValue(), + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + if (estimated == null) { + // Could not be priced; left out rather than approved against a guess. Missing from the + // report is visible, a wrong figure quietly approved is not. + continue; + } + due.add(new DueLine(((Number) row.get("account_id")).longValue(), (String) row.get("account_no"), + (String) row.get("client_name"), officeIdOf, ((Number) row.get("product_id")).longValue(), + (String) row.get("product_name"), (String) row.get("fd_type"), toLocalDate(row.get("maturity_date")), + (BigDecimal) row.get("deposit_amount"), (BigDecimal) row.get("interest_rate"), estimated, + (String) row.get("closure_instruction"), (String) row.get("closure_account_no"))); + } + return due; + } + + /** + * What {@code postMaturityInterest} would still credit this deposit, read without writing anything + * down. A separate, REQUIRES_NEW-free read: the account is loaded and discarded in this method's + * own Hibernate session with nothing marking it for a flush, since + * {@link FixedDepositAccount#previewMaturityInterestPeriods} only computes and returns periods. + * + * @return the total, zero for a deposit with nothing left to post, or null where it could not be + * priced + */ + private BigDecimal previewInterest(final long accountId, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, + final Integer financialYearBeginningMonth) { + try { + return this.readOnlyTransaction.execute(status -> { + final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(accountId, + DepositAccountType.FIXED_DEPOSIT); + final List unpaid = account.previewMaturityInterestPeriods(isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + BigDecimal total = BigDecimal.ZERO; + for (final PostingPeriod period : unpaid) { + total = total.add(period.getInterestEarned().getAmount()); + } + return total.setScale(MONEY_SCALE, MONEY_ROUNDING); + }); + } catch (final RuntimeException e) { + log.error("FD maturity report: deposit {} could not be priced and is left out of the report", accountId, e); + return null; + } + } + + // ── generation ─────────────────────────────────────────────────────────────────────────────── + + /** The noon job: generates a report for every branch with a deposit maturing and no report yet. */ + public List generateDue(final LocalDate postingDate) { + final List due = computeDue(postingDate, null); + final List generated = new ArrayList<>(); + final List failures = new ArrayList<>(); + for (final Map.Entry> group : groupByOffice(due).entrySet()) { + final Long officeId = group.getKey(); + if (activeReportId(officeId, postingDate) != null) { + continue; + } + try { + generated.add(this.transactionTemplate + .execute(status -> persistReport(officeId, postingDate, "AUTO", null, group.getValue()))); + } catch (final RuntimeException e) { + log.error("FD maturity report: branch {} could not be reported for {}", officeId, postingDate, e); + failures.add("branch " + officeId + ": " + e.getMessage()); + } + } + if (!failures.isEmpty()) { + throw new IllegalStateException( + "Fixed deposit maturity report could not be generated on " + postingDate + " for " + failures.size() + + " branch(es): " + String.join("; ", failures)); + } + return generated; + } + + /** Generates the report afresh for one branch, on the user's request. */ + public Long regenerate(final Long officeId, final LocalDate postingDate, final AppUser user) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + throw new PlatformDataIntegrityException("error.msg.fdmaturityreport.future.date", + "The report for " + postingDate + " cannot be generated before that date."); + } + authorize(user, officeId, businessDate); + final List due = computeDue(postingDate, officeId); + return this.transactionTemplate.execute(status -> persistReport(officeId, postingDate, "MANUAL", user.getId(), due)); + } + + public void approve(final Long reportId, final AppUser user) { + final List> found = this.jdbcTemplate + .queryForList("SELECT office_id, generated_by, status FROM m_fd_maturity_report WHERE id = ?", reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdmaturityreport.not.found", + "Fixed deposit maturity report " + reportId + " does not exist."); + } + final Map report = found.get(0); + final Long officeId = ((Number) report.get("office_id")).longValue(); + final Object generatedBy = report.get("generated_by"); + if (generatedBy != null && ((Number) generatedBy).longValue() == user.getId()) { + throw new PlatformDataIntegrityException("error.msg.fdmaturityreport.same.maker.checker", + "You generated this report, so you cannot approve it. Another user of this branch has to approve the maturities " + + "before they can be posted. Regenerating it yourself will not change that."); + } + final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate()); + final int approved = this.jdbcTemplate.update(""" + UPDATE m_fd_maturity_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.fdmaturityreport.not.generated", + "Only a report in GENERATED status can be approved. Reload the report — it may have been approved, " + + "regenerated or posted already."); + } + } + + private Long persistReport(final Long officeId, final LocalDate postingDate, final String mode, final Long userId, + final List lines) { + final List> previous = this.jdbcTemplate.queryForList(""" + SELECT id, status FROM m_fd_maturity_report + WHERE office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + FOR UPDATE + """, officeId, postingDate); + for (final Map report : previous) { + if (POSTED.equals(report.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.fdmaturityreport.already.posted", + "Fixed deposit maturities for this branch on " + postingDate + " have already been posted; the report cannot be" + + " regenerated."); + } + } + if (lines.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdmaturityreport.nothing.due", + "No fixed deposit is maturing in this branch on " + postingDate + "."); + } + this.jdbcTemplate.update(""" + UPDATE m_fd_maturity_report SET status = 'SUPERSEDED' + WHERE office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED') + """, officeId, postingDate); + + BigDecimal total = BigDecimal.ZERO; + for (final DueLine line : lines) { + total = total.add(line.estimatedInterest()); + } + + final Long reportId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_fd_maturity_report (office_id, posting_date, status, total_interest, account_count, generation_mode, + generated_by, generated_at) + VALUES (?, ?, 'GENERATED', ?, ?, ?, ?, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, officeId, postingDate, total, lines.size(), mode, userId); + + for (final DueLine line : lines) { + this.jdbcTemplate.update(""" + INSERT INTO m_fd_maturity_report_line + (report_id, fd_account_id, account_no, client_name, office_id, product_id, product_name, fd_type, + maturity_date, deposit_amount, interest_rate, estimated_interest, closure_instruction, closure_account_no) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, reportId, line.accountId(), line.accountNo(), line.clientName(), line.officeId(), line.productId(), + line.productName(), line.fdType(), line.maturityDate(), line.depositAmount(), line.interestRate(), + line.estimatedInterest(), line.closureInstruction(), line.closureAccountNo()); + } + + log.info("FD maturity report {}: branch {} on {} ({}) — {} across {} deposit(s)", reportId, officeId, postingDate, mode, total, + lines.size()); + return reportId; + } + + private Map> groupByOffice(final List lines) { + final Map> grouped = new LinkedHashMap<>(); + for (final DueLine line : lines) { + grouped.computeIfAbsent(line.officeId(), key -> new ArrayList<>()).add(line); + } + return grouped; + } + + // ── day close ──────────────────────────────────────────────────────────────────────────────── + + /** What stops this branch closing the day over maturities: due with no report, or an unapproved one. */ + public List reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) { + if (!this.configurationDomainService.isFixedDepositMaturityInterestPostingApprovalEnabled()) { + return List.of(); + } + return blockingFrom(officeId, businessDate, computeDue(businessDate, officeId)); + } + + /** Both what is blocking the close over maturities, and the branch's maturing deposits and amounts. */ + public Map dayCloseMaturityView(final Long officeId, final LocalDate businessDate) { + final Map view = new LinkedHashMap<>(); + if (!this.configurationDomainService.isFixedDepositMaturityInterestPostingApprovalEnabled()) { + view.put("pending", List.of()); + view.put("due", List.of()); + return view; + } + final List due = computeDue(businessDate, officeId); + view.put("pending", blockingFrom(officeId, businessDate, due)); + + BigDecimal total = BigDecimal.ZERO; + for (final DueLine line : due) { + total = total.add(line.estimatedInterest()); + } + final Map row = new LinkedHashMap<>(); + row.put("accountCount", due.size()); + row.put("totalInterest", total.setScale(MONEY_SCALE, MONEY_ROUNDING)); + row.put("reportStatus", due.isEmpty() ? "NONE" : reportStatus(officeId, businessDate)); + view.put("due", due.isEmpty() ? List.of() : List.of(row)); + return view; + } + + private List blockingFrom(final Long officeId, final LocalDate businessDate, final List due) { + final Set blocking = new LinkedHashSet<>(); + if (!due.isEmpty() && activeApprovedOrPostedReportId(officeId, businessDate) == null) { + blocking.add("Fixed deposit maturities (" + businessDate + ")"); + } + for (final Map pending : this.jdbcTemplate.queryForList(""" + SELECT posting_date FROM m_fd_maturity_report + WHERE office_id = ? AND status = 'GENERATED' AND posting_date <= ? + ORDER BY posting_date + """, officeId, businessDate)) { + blocking.add("Fixed deposit maturities (" + pending.get("posting_date") + ")"); + } + return new ArrayList<>(blocking); + } + + private String reportStatus(final Long officeId, final LocalDate postingDate) { + final List status = this.jdbcTemplate.queryForList(""" + SELECT status FROM m_fd_maturity_report + WHERE office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + ORDER BY id DESC LIMIT 1 + """, String.class, officeId, postingDate); + return status.isEmpty() ? "NONE" : status.get(0); + } + + /** The branch's approved reports, posted when it closes the day. */ + public List approvedReportIds(final Long officeId, final LocalDate upToDate) { + return this.jdbcTemplate.queryForList(""" + SELECT id FROM m_fd_maturity_report + WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ? + ORDER BY posting_date + """, Long.class, officeId, upToDate); + } + + public List approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) { + return this.jdbcTemplate.queryForList(""" + SELECT r.id FROM m_fd_maturity_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 = ? OR (b.closed_at IS NULL AND b.business_date <= ?))) + ORDER BY r.posting_date, r.office_id + """, Long.class, businessDate, headOfficeId, businessDate, businessDate); + } + + public void recordPostingFailure(final Long reportId, final String reason) { + this.jdbcTemplate.update("UPDATE m_fd_maturity_report SET failure_reason = ? WHERE id = ?", + reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId); + } + + // ── reading ────────────────────────────────────────────────────────────────────────────────── + + public List> list(final LocalDate postingDate, final String status, final Long officeId, final String scope, + final AppUser user) { + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && this.branchAuthority.isHeadOffice(userOffice); + final Long branch = others ? officeId : userOffice; + + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, 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.generated_by AS "generatedById", 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_fd_maturity_report r + 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 (branch != null) { + sql.append(" AND r.office_id = ?"); + params.add(branch); + } + if (others) { + sql.append(" AND r.office_id <> ?"); + params.add(userOffice); + } + sql.append(" ORDER BY r.posting_date DESC, o.name, r.id DESC"); + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final List> rows = new ArrayList<>(); + for (final Map row : this.jdbcTemplate.queryForList(sql.toString(), params.toArray())) { + final Map out = new LinkedHashMap<>(row); + final Object reportOffice = row.get("officeId"); + final String blocked = reportOffice == null ? "No branch on this report." + : actBlockedReason(userOffice, ((Number) reportOffice).longValue(), businessDate); + out.put("canAct", blocked == null); + out.put("blockedReason", blocked); + final Object generatedById = row.get("generatedById"); + final boolean ownWork = generatedById != null && user != null && ((Number) generatedById).longValue() == user.getId(); + out.put("canApprove", blocked == null && !ownWork && GENERATED.equals(row.get("status"))); + out.put("approveBlockedReason", ownWork ? "You generated this report — another user has to approve it." : blocked); + out.remove("generatedById"); + rows.add(out); + } + return rows; + } + + public Map detail(final Long reportId) { + final List> found = this.jdbcTemplate.queryForList(""" + SELECT r.id, 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_fd_maturity_report r + 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 r.id = ? + """, reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdmaturityreport.not.found", + "Fixed deposit maturity report " + reportId + " does not exist."); + } + final Map report = new LinkedHashMap<>(found.get(0)); + report.put("lines", this.jdbcTemplate.queryForList(""" + SELECT l.fd_account_id AS "fdAccountId", l.account_no AS "accountNo", l.client_name AS "clientName", + l.product_name AS "productName", l.fd_type AS "fdType", l.maturity_date AS "maturityDate", + l.deposit_amount AS "depositAmount", l.interest_rate AS "interestRate", + l.estimated_interest AS "estimatedInterest", l.closure_instruction AS "closureInstruction", + l.closure_account_no AS "closureAccountNo", l.posted_at AS "postedAt", l.failure_reason AS "failureReason" + FROM m_fd_maturity_report_line l + WHERE l.report_id = ? + ORDER BY l.account_no + """, reportId)); + return report; + } + + public List> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) { + final List> out = new ArrayList<>(); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + return out; + } + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && this.branchAuthority.isHeadOffice(userOffice); + final List due = computeDue(postingDate, others ? null : userOffice); + for (final Map.Entry> group : groupByOffice(due).entrySet()) { + final Long officeId = group.getKey(); + if (others == officeId.equals(userOffice)) { + continue; + } + if (activeReportId(officeId, postingDate) != null) { + continue; + } + BigDecimal total = BigDecimal.ZERO; + for (final DueLine line : group.getValue()) { + total = total.add(line.estimatedInterest()); + } + final Map row = new LinkedHashMap<>(); + row.put("officeId", officeId); + row.put("officeName", officeName(officeId)); + row.put("postingDate", postingDate.toString()); + row.put("accountCount", group.getValue().size()); + row.put("totalInterest", total.setScale(MONEY_SCALE, MONEY_ROUNDING)); + final String blocked = actBlockedReason(userOffice, officeId, businessDate); + row.put("canAct", blocked == null); + row.put("blockedReason", blocked); + out.add(row); + } + return out; + } + + /** + * Fixed deposits that matured without this report ever seeing them — a deposit that named its own + * closure instruction (Transfer to Savings, either Reinvest option) settles directly, the moment it + * matures, exactly as it always has: the approval question is for a decision nobody has made yet, + * not one the customer already made in writing when the account was opened. Read-only, for + * visibility: a branch should be able to see every maturity that has happened, approved or not. + * + *

+ * Found the same way the interest report's own reconciliation is: by what it is NOT, rather than by + * naming the closure instruction specifically — any matured deposit with no report line pointing at + * it, so a future direct path is caught here too rather than the list quietly going stale. + */ + public List> autoMatured(final LocalDate fromDate, final LocalDate toDate, final Long officeId, + final AppUser user, final String scope) { + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && this.branchAuthority.isHeadOffice(userOffice); + final Long branch = others ? officeId : userOffice; + + final StringBuilder sql = new StringBuilder(""" + SELECT sa.id AS "fdAccountId", sa.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "clientName", + COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName", + sa.product_id AS "productId", sp.name AS "productName", sp.fd_type AS "fdType", + datp.maturity_date AS "maturityDate", datp.deposit_amount AS "depositAmount", + COALESCE(sa.closedon_date, datp.maturity_date) AS "maturedOn", + """); + sql.append(CLOSURE_LABEL_CASE).append(" AS \"closureInstruction\"\n"); + sql.append(""" + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE sa.status_enum = 800 AND sa.deposit_type_enum = 200 + AND NOT EXISTS (SELECT 1 FROM m_fd_maturity_report_line l WHERE l.fd_account_id = sa.id) + """); + final List params = new ArrayList<>(); + if (fromDate != null) { + sql.append(" AND COALESCE(sa.closedon_date, datp.maturity_date) >= ?"); + params.add(fromDate); + } + if (toDate != null) { + sql.append(" AND COALESCE(sa.closedon_date, datp.maturity_date) <= ?"); + params.add(toDate); + } + if (branch != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(branch); + } else if (others) { + sql.append(" AND COALESCE(c.office_id, g.office_id) <> ?"); + params.add(userOffice); + } + sql.append(" ORDER BY sa.closedon_date DESC NULLS LAST, sa.account_no"); + return this.jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + // ── who may act ────────────────────────────────────────────────────────────────────────────── + + private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + if (userOffice == null) { + return "No branch is set for this user."; + } + if (userOffice.equals(reportOffice)) { + return null; + } + final String branch = officeName(reportOffice); + if (!this.branchAuthority.isHeadOffice(userOffice)) { + return "Only " + branch + " can generate or approve its own fixed deposit maturity report."; + } + if (this.branchAuthority.branchOpenedOn(reportOffice, businessDate)) { + return branch + " is open — it generates and approves its own report."; + } + final List stillOpen = this.branchAuthority.branchesStillOpen(businessDate, userOffice, reportOffice); + if (!stillOpen.isEmpty()) { + return "The head office can act for " + branch + " only after every open branch has closed. Still open: " + + String.join(", ", stillOpen) + "."; + } + return null; + } + + private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) { + final Long userOffice = officeOf(user); + final String blocked = actBlockedReason(userOffice, reportOffice, businessDate); + if (blocked != null) { + throw new PlatformDataIntegrityException("error.msg.fdmaturityreport.other.branch", blocked); + } + return !reportOffice.equals(userOffice); + } + + public boolean isHeadOffice(final Long officeId) { + return this.branchAuthority.isHeadOffice(officeId); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + private Long activeReportId(final Long officeId, final LocalDate postingDate) { + final List ids = this.jdbcTemplate.queryForList(""" + SELECT id FROM m_fd_maturity_report WHERE office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + """, Long.class, officeId, postingDate); + return ids.isEmpty() ? null : ids.get(0); + } + + private Long activeApprovedOrPostedReportId(final Long officeId, final LocalDate postingDate) { + final List ids = this.jdbcTemplate.queryForList(""" + SELECT id FROM m_fd_maturity_report WHERE office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED') + """, Long.class, officeId, postingDate); + return ids.isEmpty() ? null : ids.get(0); + } + + private String officeName(final Long officeId) { + return this.jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId); + } + + private static Long officeOf(final AppUser user) { + return user == null || user.getOffice() == null ? null : user.getOffice().getId(); + } + + private static LocalDate toLocalDate(final Object value) { + return value instanceof java.sql.Date date ? date.toLocalDate() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/api/FixedDepositMaturityReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/api/FixedDepositMaturityReportApiResource.java new file mode 100644 index 000000000..bc1d2b635 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/api/FixedDepositMaturityReportApiResource.java @@ -0,0 +1,198 @@ +/** + * 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.fdmaturity.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.configuration.domain.ConfigurationDomainService; +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.fdmaturity.FixedDepositMaturityReportService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +/** + * The fixed deposit maturity reports, per branch: list, detail, generate, approve, and the day-close + * check. Posting is not offered here — a branch's approved reports are posted when that branch closes + * the day, which is also when the real + * {@link org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService#updateMaturityDetails} + * actually runs for each approved deposit. + */ +@Path("/v1/fixeddepositmaturityreports") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class FixedDepositMaturityReportApiResource { + + private static final String RESOURCE = "FDMATURITYREPORT"; + + private final FixedDepositMaturityReportService reportService; + private final ConfigurationDomainService configurationDomainService; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + @GET + public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status, + @QueryParam("officeId") final Long officeId, @QueryParam("scope") final String scope) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return this.gson + .toJson(this.reportService.list(parseDate(postingDate), status, officeId, scope, user).stream().map(this::plain) + .toList()); + } + + @GET + @Path("/context") + public String userContext() { + final AppUser user = this.context.authenticatedUser(); + final Long officeId = user.getOffice().getId(); + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + result.put("officeName", user.getOffice().getName()); + result.put("headOffice", this.reportService.isHeadOffice(officeId)); + result.put("approvalEnabled", this.configurationDomainService.isFixedDepositMaturityInterestPostingApprovalEnabled()); + return this.gson.toJson(result); + } + + /** Branches with a deposit maturing by the date and no report yet. */ + @GET + @Path("/due-without-report") + public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + return this.gson.toJson(this.reportService.dueWithoutReport(date, user, scope)); + } + + @GET + @Path("/day-close-check") + public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) { + final AppUser user = this.context.authenticatedUser(); + final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date); + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final Map view = this.reportService.dayCloseMaturityView(officeId, businessDate); + @SuppressWarnings("unchecked") + final List blocking = (List) view.get("pending"); + final Map result = new LinkedHashMap<>(); + result.put("businessDate", businessDate.toString()); + result.put("canClose", blocking.isEmpty()); + result.put("pendingProducts", blocking); + result.put("due", view.get("due")); + return this.gson.toJson(result); + } + + /** + * Fixed deposits that matured without this report ever seeing them — chiefly a deposit with its own + * closure instruction, which still settles directly the moment it matures. + */ + @GET + @Path("/outside-approval") + public String autoMatured(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("officeId") final Long officeId, @QueryParam("scope") final String scope) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return this.gson.toJson( + this.reportService.autoMatured(parseDate(fromDate), parseDate(toDate), officeId, user, scope).stream().map(this::plain) + .toList()); + } + + @GET + @Path("/{reportId}") + public String detail(@PathParam("reportId") final Long reportId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE); + final Map report = plain(this.reportService.detail(reportId)); + @SuppressWarnings("unchecked") + final List> lines = (List>) report.get("lines"); + report.put("lines", lines.stream().map(this::plain).toList()); + return this.gson.toJson(report); + } + + /** {@code ?command=regenerate} with {@code {"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.fdmaturityreport.unknown.command", "Unsupported command: " + command); + } + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("REGENERATE_" + RESOURCE); + final var root = this.fromJsonHelper.parse(body); + final Long officeIdParam = this.fromJsonHelper.extractLongNamed("officeId", root); + final String postingDate = this.fromJsonHelper.extractStringNamed("postingDate", root); + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + final Long reportId = this.reportService.regenerate(officeId, date, user); + return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositMaturityReportService.GENERATED)); + } + + @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.fdmaturityreport.unknown.command", "Unsupported command: " + command); + } + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("APPROVE_" + RESOURCE); + this.reportService.approve(reportId, user); + return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositMaturityReportService.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.fdmaturityreport.invalid.date", "Dates are expected as yyyy-MM-dd, not " + + raw); + } + } + + 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/fdmaturity/jobs/FixedDepositMaturityReportConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportConfig.java new file mode 100644 index 000000000..d56cb00e9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportConfig.java @@ -0,0 +1,52 @@ +/** + * 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.fdmaturity.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 FixedDepositMaturityReportConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step fdMaturityReportStep(final FixedDepositMaturityReportTasklet fdMaturityReportTasklet) { + return new StepBuilder(JobName.FD_MATURITY_REPORT.name(), jobRepository).tasklet(fdMaturityReportTasklet, transactionManager) + .build(); + } + + @Bean + public Job fdMaturityReportJob(final FixedDepositMaturityReportTasklet fdMaturityReportTasklet) { + return new JobBuilder(JobName.FD_MATURITY_REPORT.name(), jobRepository).start(fdMaturityReportStep(fdMaturityReportTasklet)) + .incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportTasklet.java new file mode 100644 index 000000000..f4b498a4e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportTasklet.java @@ -0,0 +1,57 @@ +/** + * 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.fdmaturity.jobs; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.fdmaturity.FixedDepositMaturityReportService; +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 maturity report for every branch with a fixed deposit + * maturing, so it can be reviewed and approved before the branch closes the day. + * + *

+ * Does nothing while the gate is off — the nightly maturity job settles matured deposits directly, and + * a report generated alongside it would be approved against an event that has already happened. + */ +@RequiredArgsConstructor +@Component +@Slf4j +public class FixedDepositMaturityReportTasklet implements Tasklet { + + private final FixedDepositMaturityReportService reportService; + private final ConfigurationDomainService configurationDomainService; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) throws Exception { + if (!this.configurationDomainService.isFixedDepositMaturityInterestPostingApprovalEnabled()) { + log.info("FD maturity report: skipped — fixed deposit maturities are settled directly by the nightly job"); + return RepeatStatus.FINISHED; + } + this.reportService.generateDue(DateUtils.getBusinessLocalDate()); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsConfig.java index 0a02bfc77..83cb843de 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsConfig.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.savings.jobs.updatedepositsaccountmaturitydetails; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.jobs.service.JobName; import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService; import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; @@ -30,6 +31,7 @@ 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.jdbc.core.JdbcTemplate; import org.springframework.transaction.PlatformTransactionManager; @Configuration @@ -43,6 +45,10 @@ public class UpdateDepositsAccountMaturityDetailsConfig { private DepositAccountReadPlatformService depositAccountReadPlatformService; @Autowired private DepositAccountWritePlatformService depositAccountWritePlatformService; + @Autowired + private ConfigurationDomainService configurationDomainService; + @Autowired + private JdbcTemplate jdbcTemplate; @Bean protected Step updateDepositsAccountMaturityDetailsStep() { @@ -58,6 +64,7 @@ public class UpdateDepositsAccountMaturityDetailsConfig { @Bean public UpdateDepositsAccountMaturityDetailsTasklet updateDepositsAccountMaturityDetailsTasklet() { - return new UpdateDepositsAccountMaturityDetailsTasklet(depositAccountReadPlatformService, depositAccountWritePlatformService); + return new UpdateDepositsAccountMaturityDetailsTasklet(depositAccountReadPlatformService, depositAccountWritePlatformService, + configurationDomainService, jdbcTemplate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java index 5f956586a..7e8a6760c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java @@ -22,6 +22,7 @@ import java.util.Collection; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; @@ -33,6 +34,7 @@ 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.jdbc.core.JdbcTemplate; @Slf4j @RequiredArgsConstructor @@ -40,15 +42,27 @@ public class UpdateDepositsAccountMaturityDetailsTasklet implements Tasklet { private final DepositAccountReadPlatformService depositAccountReadPlatformService; private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + private final JdbcTemplate jdbcTemplate; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { final Collection depositAccounts = depositAccountReadPlatformService.retrieveForMaturityUpdate(); + // With the gate on, a fixed deposit with NO maturity instruction waits for + // FixedDepositMaturityReportService's report to be approved before it settles, rather than this + // job running it directly the moment it matures. One that carries its own instruction (Transfer + // to Savings, either Reinvest option) still settles here, exactly as it always has — the + // approval question does not apply to a decision the customer already made in writing, only to + // the one nobody has made yet. Recurring deposits are unaffected either way. + final boolean fdMaturityGated = this.configurationDomainService.isFixedDepositMaturityInterestPostingApprovalEnabled(); for (final DepositAccountData depositAccount : depositAccounts) { try { final DepositAccountType depositAccountType = DepositAccountType .fromInt(depositAccount.getDepositType().getId().intValue()); + if (fdMaturityGated && depositAccountType.isFixedDeposit() && hasNoClosureInstruction(depositAccount.getId())) { + continue; + } depositAccountWritePlatformService.updateMaturityDetails(depositAccount.getId(), depositAccountType); } catch (final PlatformApiDataValidationException e) { final List errors = e.getErrors(); @@ -65,4 +79,12 @@ public class UpdateDepositsAccountMaturityDetailsTasklet implements Tasklet { depositAccounts.size()); return RepeatStatus.FINISHED; } + + /** True where the account names no closure instruction, including one with no term row at all. */ + private boolean hasNoClosureInstruction(final Long accountId) { + final List found = this.jdbcTemplate.queryForList( + "SELECT on_account_closure_enum FROM m_deposit_account_term_and_preclosure WHERE savings_account_id = ?", Integer.class, + accountId); + return found.isEmpty() || found.get(0) == null || found.get(0) == 0; + } } 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 470c64207..b8115ea1b 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 @@ -166,4 +166,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_fd_maturity_interest_posting_approval.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_fd_maturity_interest_posting_approval.xml new file mode 100644 index 000000000..d7e28ad9b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_fd_maturity_interest_posting_approval.xml @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Generate Fixed Deposit Maturity Report' + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_FDMATURITYREPORT', + 'REGENERATE_FDMATURITYREPORT', 'APPROVE_FDMATURITYREPORT') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_FDMATURITYREPORT', 'REGENERATE_FDMATURITYREPORT', + 'APPROVE_FDMATURITYREPORT') + + + + 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_FDMATURITYREPORT', 'REGENERATE_FDMATURITYREPORT', + 'APPROVE_FDMATURITYREPORT') + 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 + ) + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'fd-maturity-interest-posting-approval' + + + + + + + + + + + +