From 949f45c4cda1d10d7e6cc98464e6d85f47e98e49 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 7 Oct 2026 22:40:57 +0530 Subject: [PATCH 1/7] feat(fixed-deposits): FD interest posting approval with pooled GL postings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a maker-checker approval report in front of fixed deposit interest posting, mirroring the existing savings interest posting report: a report is generated per branch and product (SINGLE/DOUBLE/SPECIAL), a second user approves it, and it posts when the branch closes its day. Day close is blocked until deposit interest due that day is approved. Ledger entries are pooled per the report's interestPostingDate rather than raised per deposit: Dr Interest Expense and the Payable/Paid legs are each raised once for the group's total, and only the final leg crediting each deposit's own GL is per account — the same pattern the savings interest posting report already uses, instead of repeating the full three-stage chain for every deposit. Co-Authored-By: Claude Sonnet 5 --- .../api/GlobalConfigurationConstants.java | 1 + .../domain/ConfigurationDomainService.java | 6 + .../infrastructure/jobs/service/JobName.java | 1 + .../portfolio/savings/FixedDepositType.java | 100 ++ .../journalentry/service/LedgerTxnPrefix.java | 4 + .../domain/ConfigurationDomainServiceJpa.java | 10 + .../api/BranchDayRegisterApiResource.java | 42 + ...xedDepositInterestPostingOrchestrator.java | 93 ++ ...edDepositInterestPostingReportService.java | 893 ++++++++++++++++++ .../FixedDepositInterestPostingService.java | 477 ++++++++++ ...positInterestPostingReportApiResource.java | 229 +++++ ...xedDepositInterestPostingReportConfig.java | 52 + ...edDepositInterestPostingReportTasklet.java | 57 ++ ...SavingsAccountReadPlatformServiceImpl.java | 71 +- .../savings/starter/SavingsConfiguration.java | 4 +- .../tenant/final-changelog-tenant.xml | 1 + .../0593_fd_interest_posting_approval.xml | 334 +++++++ .../SavingsAccountReadPlatformService.java | 12 + 18 files changed, 2383 insertions(+), 4 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/savings/FixedDepositType.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingOrchestrator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportTasklet.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_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 d62a646e1..41cd2bf0b 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 @@ -32,6 +32,7 @@ public final class GlobalConfigurationConstants { public static final String FORCE_PASSWORD_RESET_DAYS = "force-password-reset-days"; 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 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 2529cee37..fb0c03c76 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 @@ -69,6 +69,12 @@ public interface ConfigurationDomainService { boolean isSavingsInterestPostingAtCurrentPeriodEnd(); + /** + * Whether fixed deposit interest is reported and approved before it is posted, instead of being + * posted directly by the nightly job. + */ + boolean isFixedDepositInterestPostingApprovalEnabled(); + 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 93e097cc6..b442ca1af 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 @@ -44,6 +44,7 @@ public enum JobName { 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"), // + FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit 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-core/src/main/java/org/apache/fineract/portfolio/savings/FixedDepositType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/FixedDepositType.java new file mode 100644 index 000000000..cff5fae02 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/FixedDepositType.java @@ -0,0 +1,100 @@ +/** + * 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; + +import java.util.Locale; + +/** + * The deposit scheme a fixed deposit product is sold under, held as {@code m_savings_product.fd_type}. + * + *

+ * The three schemes differ in where a posting of interest ends up, which is what the interest posting + * report has to show and the posting itself has to honour: + * + *

+ * + *

+ * The database column is a string rather than an enum ordinal, so the names here are the stored + * values and must not be renamed. {@link #fromString(String)} is lenient about case and blanks + * because the column carries a default of SINGLE and older products predate the column entirely. + */ +public enum FixedDepositType { + + /** Interest is held in the deposit and paid at maturity. The default for a product that names none. */ + SINGLE, + /** Interest is compounded into the deposit; the maturity value includes it. */ + DOUBLE, + /** Interest is posted monthly and transferred to the linked savings account. */ + SPECIAL; + + /** + * The scheme named by a stored {@code fd_type}, defaulting to {@link #SINGLE}. + * + *

+ * A null, blank or unrecognised value reads as SINGLE: that is the column's own default, and a + * product carrying something else is a misconfiguration that must not stop its customers' interest + * from being reported. + */ + public static FixedDepositType fromString(final String fdType) { + if (fdType == null || fdType.isBlank()) { + return SINGLE; + } + for (final FixedDepositType candidate : values()) { + if (candidate.name().equalsIgnoreCase(fdType.trim())) { + return candidate; + } + } + return SINGLE; + } + + public static boolean isValid(final String fdType) { + if (fdType == null || fdType.isBlank()) { + return false; + } + for (final FixedDepositType candidate : values()) { + if (candidate.name().equalsIgnoreCase(fdType.trim())) { + return true; + } + } + return false; + } + + /** Whether interest leaves the deposit for the linked savings account each time it is posted. */ + public boolean isPaidOutMonthly() { + return this == SPECIAL; + } + + /** Whether a posting of interest adds to the deposit's own balance. */ + public boolean isRetainedInDeposit() { + return this != SPECIAL; + } + + /** What a customer sees: "FD Special", "FD Double", "FD Single". */ + public String displayName() { + return "FD " + name().charAt(0) + name().substring(1).toLowerCase(Locale.ENGLISH); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java index d31549dbb..b8b1f5799 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java @@ -84,4 +84,8 @@ public final class LedgerTxnPrefix { // ── Savings ─────────────────────────────────────────────────────────────── /** Pooled savings interest posting for one product and date. */ public static final String SAVINGS_INTEREST_POOLED = "SAVINT"; + + // ── Fixed deposits ──────────────────────────────────────────────────────── + /** Approved fixed deposit interest posting report, for one product, branch and date. */ + public static final String FD_INTEREST_POSTED = "FDINT"; } 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 c393095d9..2b5f99d3b 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 @@ -191,6 +191,16 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService return property.isEnabled(); } + /** + * Read on every run of the interest posting job and of the day close, so the two paths can never + * both pay the same deposit: with this on the job leaves fixed deposits alone and they are paid + * from an approved report, with it off the job pays them directly as it always did. + */ + @Override + public boolean isFixedDepositInterestPostingApprovalEnabled() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_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 de005116a..79720f616 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 @@ -48,6 +48,8 @@ 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.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator; +import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -91,6 +93,8 @@ public class BranchDayRegisterApiResource { private final VaultPositionService vaultPositionService; private final SavingsInterestPostingReportService interestPostingReportService; private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; + private final FixedDepositInterestPostingReportService fdInterestPostingReportService; + private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator; private final Gson gson = new Gson(); // ── GET /v1/branchday ── the register itself ────────────────────────────── @@ -232,6 +236,16 @@ public class BranchDayRegisterApiResource { result.put("denominationIn", countedIn); result.put("denominationOut", countedOut); + // What will stop this close, so the screen can say so before the button is pressed rather than + // after. The same lists closeDay() refuses on, read-only here. + result.put("savingsInterestPending", interestPostingReportService.reportsBlockingDayClose(officeId, businessDate)); + // One call for the deposits: what is blocking, and the products and amounts behind it, so the + // branch sees what it is being asked to approve without leaving the close screen. Pricing a + // branch's deposits is the expensive part and is done once. + final Map fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate); + result.put("fdInterestPending", fdInterest.get("pending")); + result.put("fdInterestDue", fdInterest.get("due")); + return gson.toJson(result); } @@ -610,6 +624,17 @@ public class BranchDayRegisterApiResource { + ". Approve it under Reports → Interest Posting Reports, then close the day."); } + // ── And its fixed deposit interest reports, on the same grounds ── + // Only while deposits are on the approval path at all; with that switched off the posting job + // pays them directly and there is nothing here to approve. + final List fdInterestPending = fdInterestPostingReportService.reportsBlockingDayClose(officeId, businessDate); + if (!fdInterestPending.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.report.pending", + "Cannot close the day — this branch's fixed deposit interest posting report is not approved for: " + + String.join(", ", fdInterestPending) + + ". Approve it under Reports → FD 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. @@ -745,6 +770,23 @@ public class BranchDayRegisterApiResource { } } + // ── And the branch's approved fixed deposit interest, the same way ── + // Separate from the savings run so a deposit product with a GL problem cannot stop savings + // interest that has already posted cleanly, and so the refusal names which of the two failed. + final List fdToPost = new ArrayList<>(fdInterestPostingReportService.approvedReportIds(officeId, businessDate)); + if (isHeadOffice) { + fdToPost.addAll(fdInterestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId)); + } + if (!fdToPost.isEmpty()) { + log.info("Day close {}: office {} posting {} approved FD interest report(s)", businessDate, officeId, fdToPost.size()); + final List fdFailures = fdInterestPostingOrchestrator.postReports(fdToPost); + if (!fdFailures.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.posting.failed", + "Cannot close the day — fixed deposit interest could not be posted: " + String.join("; ", fdFailures) + + ". 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/fdinterest/FixedDepositInterestPostingOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingOrchestrator.java new file mode 100644 index 000000000..fb6e8bee3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingOrchestrator.java @@ -0,0 +1,93 @@ +/** + * 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.fdinterest; + +import java.math.BigDecimal; +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.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 interest reports, one transaction each. + * + *

+ * Called as a branch closes its day. A report that cannot be posted does not take the others down + * with it: its failure is written to the report, which stays APPROVED, and reported back to the + * caller so the close can be refused with a reason the branch can act on. Closing again once the + * cause is fixed posts it. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class FixedDepositInterestPostingOrchestrator { + + private final JdbcTemplate jdbcTemplate; + private final FixedDepositInterestPostingService postingService; + private final FixedDepositInterestPostingReportService reportService; + private final PlatformTransactionManager transactionManager; + + /** + * Posts each report in a transaction of its own. + * + * @return one message per report that could not be posted, empty where all of them were + */ + 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 Map report = this.jdbcTemplate + .queryForMap("SELECT fd_product_id, posting_date FROM m_fd_interest_posting_report WHERE id = ?", reportId); + final long productId = ((Number) report.get("fd_product_id")).longValue(); + final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + try { + final BigDecimal paid = newTransaction.execute(status -> this.postingService.postReport(reportId)); + log.info("FD interest: report {} for product {} posted {} for {}", reportId, productId, paid, postingDate); + } catch (final RuntimeException e) { + log.error("FD interest: report {} for product {} failed to post for {}", reportId, productId, postingDate, e); + newTransaction.executeWithoutResult(status -> { + this.reportService.recordPostingFailure(reportId, e.getMessage()); + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_run + SET status = 'FAILED', failure_reason = ? + WHERE report_id = ? + """, truncate(e.getMessage()), reportId); + }); + failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage()); + } + } + return failures; + } + + 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/fdinterest/FixedDepositInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java new file mode 100644 index 000000000..2dc8695f2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java @@ -0,0 +1,893 @@ +/** + * 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.fdinterest; + +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 java.util.stream.Collectors; +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.FixedDepositType; +import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +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 interest posting report: what each deposit 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 deposits with interest due; a user of that branch may regenerate it at any time — the + * previous report is superseded, not overwritten — and a second user of that branch approves it. The + * branch's approved reports are posted when it closes its day, and until they are approved it cannot + * close. The head office closes last, once every branch open that day has closed. + * + *

+ * Where the interest ends up is the product's scheme, and the report says so deposit by deposit: + * a {@link FixedDepositType#SPECIAL} deposit's interest leaves for the customer's linked savings + * account, while {@link FixedDepositType#SINGLE} and {@link FixedDepositType#DOUBLE} keep it in the + * deposit until maturity. A checker approving a special product's report is approving payments out + * of the bank, which is why the destination account is on every line. + * + *

+ * Unlike savings, a deposit has no daily interest ledger to read a figure from: the amount is worked + * out by the same interest engine the posting job uses, run here without the step that writes it + * down. That makes generation the expensive part and it is done once per run, not once per screen. + */ +@Service +@Slf4j +public class FixedDepositInterestPostingReportService { + + 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 final JdbcTemplate jdbcTemplate; + private final SavingsAccountReadPlatformService savingsAccountReadPlatformService; + private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + private final FixedDepositInterestPostingService postingService; + /** + * Borrowed for the branch rules — which branch may act for which, and whether a branch is working + * today. They are the same rules as for savings and must stay the same: a bank where a branch + * could approve its deposits but not its savings, or the other way round, would be answering the + * question "who closes the day" twice with two different answers. + */ + private final SavingsInterestPostingReportService branchAuthority; + private final TransactionTemplate transactionTemplate; + + public FixedDepositInterestPostingReportService(final JdbcTemplate jdbcTemplate, + final SavingsAccountReadPlatformService savingsAccountReadPlatformService, + final SavingsAccountWritePlatformService savingsAccountWritePlatformService, + final ConfigurationDomainService configurationDomainService, final FixedDepositInterestPostingService postingService, + final SavingsInterestPostingReportService branchAuthority, final PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.savingsAccountReadPlatformService = savingsAccountReadPlatformService; + this.savingsAccountWritePlatformService = savingsAccountWritePlatformService; + this.configurationDomainService = configurationDomainService; + this.postingService = postingService; + this.branchAuthority = branchAuthority; + // A transaction of its own, as for savings: the noon job runs inside the batch step's + // transaction, and a report committed separately is on screen at once and holds no row locks + // for the rest of the run. + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + /** + * One posting due on one deposit, before it is written down — a deposit with two unpaid periods + * has two of these, one per period, because that is how the engine will look for them again. + */ + public record DueLine(Long accountId, String accountNo, String clientName, Long officeId, Long productId, String fdType, + LocalDate postingDate, BigDecimal interest, BigDecimal balanceBefore, BigDecimal depositAmount, BigDecimal interestRate, + LocalDate maturityDate, Long payoutSavingsAccountId, String payoutAccountNo) {} + + /** One unpaid posting the engine worked out: the date it belongs to and what it comes to. */ + private record ComputedPosting(Long accountId, LocalDate postingDate, BigDecimal interest) {} + + /** A product with interest due in one branch. */ + private record DueKey(Long productId, Long officeId) {} + + // ── computing what is due ──────────────────────────────────────────────────────────────────── + + /** + * What every candidate fixed deposit has earned and not yet been paid, as at the posting date. + * + *

+ * The interest engine is run exactly as the posting job runs it, and then stopped: it returns the + * deposit with its new interest transactions attached in memory, and only the job's batch write + * turns those into rows. Nothing here writes one, so this can be called as often as a screen asks. + * + *

+ * A deposit the engine finds nothing for — its posting period has not ended yet — contributes no + * line. That is also what makes "due" meaningful at all for a deposit: the cheap database test + * that selects candidates cannot tell a period end from a day in the middle of one. + * + * @param officeId + * one branch, or null for every branch + * @param productId + * one product, or null for every product + */ + public List computeDueInterest(final LocalDate postingDate, final Long officeId, final Long productId) { + final boolean backdatedTxnsAllowedTill = this.configurationDomainService.retrievePivotDateConfig(); + final List candidates = this.savingsAccountReadPlatformService + .retrieveFixedDepositsDueForInterestPosting(backdatedTxnsAllowedTill, postingDate, officeId, productId); + if (candidates.isEmpty()) { + return List.of(); + } + + final List postings = new ArrayList<>(); + for (final SavingsAccountData deposit : candidates) { + postings.addAll(computePostingsFor(deposit, backdatedTxnsAllowedTill)); + } + if (postings.isEmpty()) { + return List.of(); + } + return describe(postings); + } + + /** + * One deposit's unpaid postings, each with the date the engine gave it. + * + *

+ * The date is kept, never flattened away. The engine decides a period has already been paid by + * looking for an interest transaction on exactly that date, so a posting recorded under any other + * date would be invisible to it and the period would be computed, reported and paid a second time. + * For the same reason two unpaid periods stay two postings rather than being added together. + * + *

+ * A deposit the engine cannot price — a chart it cannot read, a rate it cannot resolve — is left + * out of the report rather than stopping the branch's other customers from being reported. Missing + * from the report is visible; a wrong figure quietly paid is not. + */ + private List computePostingsFor(final SavingsAccountData deposit, final boolean backdatedTxnsAllowedTill) { + final SavingsAccountData computed; + try { + computed = this.savingsAccountWritePlatformService.postInterest(deposit, false, null, backdatedTxnsAllowedTill); + } catch (final RuntimeException e) { + log.error("FD interest report: deposit {} could not be priced and is left out of the report", deposit.getId(), e); + return List.of(); + } + if (computed == null || computed.getSavingsAccountTransactionData() == null) { + return List.of(); + } + final List postings = new ArrayList<>(); + for (final SavingsAccountTransactionData transaction : computed.getSavingsAccountTransactionData()) { + // Only what this run has just worked out: a transaction with an id is already in the + // account and has been paid. + if (transaction.getId() != null || transaction.getTransactionType() == null + || transaction.getTransactionType().getId() == null) { + continue; + } + final SavingsAccountTransactionType type = SavingsAccountTransactionType + .fromInt(transaction.getTransactionType().getId().intValue()); + if (!type.isInterestPosting() || transaction.getAmount() == null + || transaction.getAmount().compareTo(BigDecimal.ZERO) <= 0 || transaction.getTransactionDate() == null) { + continue; + } + postings.add(new ComputedPosting(deposit.getId(), transaction.getTransactionDate(), transaction.getAmount())); + } + return postings; + } + + /** + * The deposits behind the computed figures, with what a checker needs to judge them: the principal, + * the rate, the maturity date, and for a special deposit the savings account its interest leaves + * for. + */ + private List describe(final List postings) { + final String ids = postings.stream().map(ComputedPosting::accountId).distinct().map(String::valueOf) + .collect(Collectors.joining(",")); + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS account_id, sa.account_no, sa.product_id, + COALESCE(c.display_name, g.display_name) AS client_name, + COALESCE(c.office_id, g.office_id) AS office_id, + COALESCE(sa.account_balance_derived, 0) AS balance, + sa.nominal_annual_interest_rate AS interest_rate, + sp.fd_type, datp.deposit_amount, datp.maturity_date, + datp.transfer_interest_to_linked_account, datp.transfer_to_savings_account_id, + ls.account_no AS payout_account_no + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + LEFT 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.id IN (%s) + ORDER BY sa.account_no + """.formatted(ids)); + + final Map> byAccount = new LinkedHashMap<>(); + for (final Map row : rows) { + byAccount.put(((Number) row.get("account_id")).longValue(), row); + } + + final List lines = new ArrayList<>(); + for (final ComputedPosting posting : postings) { + final Map row = byAccount.get(posting.accountId()); + if (row == null) { + continue; + } + final Object officeId = row.get("office_id"); + if (officeId == null) { + // A deposit whose customer has no branch cannot be put on any branch's report. + log.warn("FD interest report: deposit {} has no branch and is left out", posting.accountId()); + continue; + } + final FixedDepositType fdType = FixedDepositType.fromString((String) row.get("fd_type")); + // The destination is only meaningful where the scheme actually pays out and the deposit + // names an account to pay into. + final boolean paysOut = fdType.isPaidOutMonthly() && Boolean.TRUE.equals(row.get("transfer_interest_to_linked_account")); + final Object payoutId = paysOut ? row.get("transfer_to_savings_account_id") : null; + lines.add(new DueLine(posting.accountId(), (String) row.get("account_no"), (String) row.get("client_name"), + ((Number) officeId).longValue(), ((Number) row.get("product_id")).longValue(), fdType.name(), + posting.postingDate(), posting.interest(), (BigDecimal) row.get("balance"), + (BigDecimal) row.get("deposit_amount"), (BigDecimal) row.get("interest_rate"), + toLocalDate(row.get("maturity_date")), payoutId == null ? null : ((Number) payoutId).longValue(), + paysOut ? (String) row.get("payout_account_no") : null)); + } + return lines; + } + + private static LocalDate toLocalDate(final Object value) { + return value instanceof java.sql.Date date ? date.toLocalDate() : null; + } + + // ── generation ─────────────────────────────────────────────────────────────────────────────── + + /** + * The noon job: generates a report for every branch and product with deposit interest due that + * does not already have one. + * + *

+ * The interest is worked out once for the whole bank and then split by branch and product, since + * pricing a deposit is the expensive part and doing it per report would price the same portfolio + * many times over. Each report is still written 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) { + final List due = computeDueInterest(postingDate, null, null); + final List generated = new ArrayList<>(); + final List failures = new ArrayList<>(); + for (final Map.Entry> group : groupByProductAndOffice(due).entrySet()) { + final DueKey key = group.getKey(); + if (activeReportId(key.productId(), key.officeId(), postingDate) != null) { + continue; + } + try { + generated.add(transactionTemplate + .execute(status -> persistReport(key.productId(), key.officeId(), postingDate, "AUTO", null, group.getValue()))); + } catch (final RuntimeException e) { + log.error("FD interest report: product {} branch {} could not be reported for {}", key.productId(), key.officeId(), + postingDate, e); + failures.add("product " + key.productId() + " branch " + key.officeId() + ": " + e.getMessage()); + } + } + if (!failures.isEmpty()) { + throw new IllegalStateException("Fixed deposit 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. + * + *

+ * 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.fdinterestreport.future.date", + "The report for " + postingDate + " cannot be generated before that date."); + } + authorize(user, officeId, businessDate); + final List due = computeDueInterest(postingDate, officeId, productId); + return transactionTemplate.execute(status -> persistReport(productId, officeId, postingDate, "MANUAL", user.getId(), due)); + } + + /** + * Approves a generated report, by someone other than the user who generated it. + * + *

+ * The two-user rule is the point of the report. Interest posting moves money to customers — and + * out of the bank entirely, for a special deposit paying into a linked savings account — so the + * figures are put up by one person and signed off by another. A branch that cannot find a second + * user has a staffing problem, not a software one; the alternative is a control that any single + * user can walk straight through. + * + *

+ * 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 = this.jdbcTemplate + .queryForList("SELECT office_id, generated_by, status FROM m_fd_interest_posting_report WHERE id = ?", reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.not.found", + "Fixed deposit interest posting 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.fdinterestreport.same.maker.checker", + "You generated this report, so you cannot approve it. Another user of this branch has to approve the interest " + + "before it 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_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.fdinterestreport.not.generated", + "Only a report in GENERATED status can be approved. Reload the report — it may have been approved, " + + "regenerated or posted already."); + } + } + + /** + * Writes one report and its lines, superseding whatever stood for that branch, product and date. + * + *

+ * Runs inside the caller's transaction. Each deposit's interest is rounded to two decimals and the + * paise the rounding loses is given to the largest deposit, where it is proportionally smallest, + * so the report's total is exactly what the branch will pay out. + */ + private Long persistReport(final Long productId, 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_interest_posting_report + WHERE fd_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.fdinterestreport.already.posted", + "Fixed deposit interest for this product and branch on " + postingDate + " has already been posted; the report" + + " cannot be regenerated."); + } + } + if (lines.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.nothing.due", + "No fixed deposit interest is due for this product in this branch on " + postingDate + "."); + } + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_report SET status = 'SUPERSEDED' + WHERE fd_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED') + """, productId, officeId, postingDate); + + // Refused here, before anybody approves figures that could never be posted. + final FixedDepositInterestPostingService.PostingGlAccounts gls = this.postingService.resolvePostingGlAccounts(productId); + + // Oldest period first, so a deposit owed two postings is written down in the order the money + // would actually have reached it, and each line's opening balance follows the one before. + final List ordered = lines.stream() + .sorted(Comparator.comparing(DueLine::postingDate).thenComparing(DueLine::accountNo, + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + // Rounded per line, not per deposit: one deposit can hold more than one line here. + final List rounded = new ArrayList<>(ordered.size()); + BigDecimal grossTotal = BigDecimal.ZERO; + BigDecimal roundedTotal = BigDecimal.ZERO; + for (final DueLine line : ordered) { + final BigDecimal value = line.interest().setScale(MONEY_SCALE, MONEY_ROUNDING); + rounded.add(value); + grossTotal = grossTotal.add(line.interest()); + roundedTotal = roundedTotal.add(value); + } + final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal residual = pooled.subtract(roundedTotal); + if (residual.signum() != 0) { + int largest = 0; + for (int i = 1; i < ordered.size(); i++) { + final BigDecimal candidate = ordered.get(i).balanceBefore(); + final BigDecimal best = ordered.get(largest).balanceBefore(); + if ((candidate == null ? BigDecimal.ZERO : candidate).compareTo(best == null ? BigDecimal.ZERO : best) > 0) { + largest = i; + } + } + rounded.set(largest, rounded.get(largest).add(residual)); + } + + final String fdType = ordered.get(0).fdType(); + final Long reportId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_fd_interest_posting_report + (fd_product_id, fd_type, 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, fdType, officeId, postingDate, pooled, ordered.size(), gls.payableGl(), gls.paidGl(), mode, + userId); + + // Where a deposit holds two postings, the second opens on the balance the first leaves behind. + final Map runningBalance = new LinkedHashMap<>(); + for (int i = 0; i < ordered.size(); i++) { + final DueLine line = ordered.get(i); + final BigDecimal interest = rounded.get(i); + final BigDecimal opening = line.balanceBefore() == null ? BigDecimal.ZERO + : line.balanceBefore().setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal before = runningBalance.getOrDefault(line.accountId(), opening); + // The deposit's balance the moment the interest is credited. A special deposit's interest + // does not stay there — the existing transfer-interest-to-savings job sweeps it to the + // linked account afterwards, which is a transaction of its own — so the destination is on + // the line rather than being netted off here, where it would hide a payment the checker is + // approving. + final BigDecimal after = before.add(interest); + runningBalance.put(line.accountId(), after); + this.jdbcTemplate.update(""" + INSERT INTO m_fd_interest_posting_report_line + (report_id, fd_account_id, account_no, client_name, office_id, interest_posting_date, interest_amount, + balance_before, balance_after, deposit_amount, interest_rate, maturity_date, payout_savings_account_id, + payout_account_no) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, reportId, line.accountId(), line.accountNo(), line.clientName(), line.officeId(), line.postingDate(), interest, + before, after, line.depositAmount(), line.interestRate(), line.maturityDate(), line.payoutSavingsAccountId(), + line.payoutAccountNo()); + } + + log.info("FD interest report {}: product {} ({}) branch {} on {} ({}) — {} across {} posting(s)", reportId, productId, fdType, + officeId, postingDate, mode, pooled, ordered.size()); + return reportId; + } + + private Map> groupByProductAndOffice(final List lines) { + final Map> grouped = new LinkedHashMap<>(); + for (final DueLine line : lines) { + grouped.computeIfAbsent(new DueKey(line.productId(), line.officeId()), key -> new ArrayList<>()).add(line); + } + return grouped; + } + + // ── day close ──────────────────────────────────────────────────────────────────────────────── + + /** + * What stops one branch from closing the day: a product with deposit 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, which is why the due figures are worked out here rather than + * trusting the noon job to have run. + */ + public List reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) { + if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) { + return List.of(); + } + return blockingFrom(officeId, businessDate, groupByProductAndOffice(computeDueInterest(businessDate, officeId, null))); + } + + /** + * What the day close screen shows a branch about its deposits: what is blocking the close, and the + * products and amounts behind it. + * + *

+ * Both come out of one pricing run, because working out a branch's deposit interest is the + * expensive part and the screen asks for both at once. + * + * @return {@code pending} — the same list {@link #reportsBlockingDayClose} returns — and {@code due}, + * one row per product with its deposit count and total + */ + public Map dayCloseInterestView(final Long officeId, final LocalDate businessDate) { + final Map view = new LinkedHashMap<>(); + if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) { + view.put("pending", List.of()); + view.put("due", List.of()); + return view; + } + final Map> grouped = groupByProductAndOffice(computeDueInterest(businessDate, officeId, null)); + view.put("pending", blockingFrom(officeId, businessDate, grouped)); + + final List> due = new ArrayList<>(); + for (final Map.Entry> group : grouped.entrySet()) { + BigDecimal total = BigDecimal.ZERO; + for (final DueLine line : group.getValue()) { + total = total.add(line.interest()); + } + final Map row = new LinkedHashMap<>(); + row.put("productId", group.getKey().productId()); + row.put("productName", productName(group.getKey().productId())); + row.put("fdType", group.getValue().get(0).fdType()); + row.put("accountCount", group.getValue().size()); + row.put("totalInterest", total.setScale(MONEY_SCALE, MONEY_ROUNDING)); + row.put("reportStatus", reportStatus(group.getKey().productId(), officeId, businessDate)); + due.add(row); + } + view.put("due", due); + return view; + } + + /** Products due with no approved report, plus any of the branch's reports still awaiting approval. */ + private List blockingFrom(final Long officeId, final LocalDate businessDate, final Map> grouped) { + final Set blocking = new LinkedHashSet<>(); + for (final DueKey due : grouped.keySet()) { + final Long settled = this.jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_fd_interest_posting_report + WHERE fd_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 : this.jdbcTemplate.queryForList(""" + SELECT p.name, r.posting_date FROM m_fd_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_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); + } + + /** Where this product's report for the branch and date stands, or NONE where there is not one. */ + private String reportStatus(final Long productId, final Long officeId, final LocalDate postingDate) { + final List status = this.jdbcTemplate.queryForList(""" + SELECT status FROM m_fd_interest_posting_report + WHERE fd_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + ORDER BY id DESC LIMIT 1 + """, String.class, productId, 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_interest_posting_report + WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ? + ORDER BY posting_date, fd_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 this.jdbcTemplate.queryForList(""" + SELECT r.id FROM m_fd_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 = ? OR (b.closed_at IS NULL AND b.business_date <= ?))) + ORDER BY r.posting_date, r.office_id, r.fd_product_id + """, Long.class, businessDate, headOfficeId, businessDate, businessDate); + } + + /** 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) { + this.jdbcTemplate.update("UPDATE m_fd_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. {@code scope} "own" (the default) is the user's own branch; "others", for + * a head office user, is every other branch (or one, with officeId). canAct says whether this user + * may regenerate the report, canApprove whether they may approve it, and blockedReason why not. + */ + public List> list(final LocalDate postingDate, final String status, final Long productId, 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.fd_product_id AS "productId", p.name AS "productName", r.fd_type AS "fdType", + 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_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_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.fd_product_id = ?"); + params.add(productId); + } + 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, p.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); + // The maker cannot be the checker, and the screen says so before the button is pressed. + 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.fd_product_id AS "productId", p.name AS "productName", r.fd_type AS "fdType", + 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" + FROM m_fd_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_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 + WHERE r.id = ? + """, reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.not.found", + "Fixed deposit interest posting 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.interest_posting_date AS "interestPostingDate", l.interest_amount AS "interestAmount", + l.balance_before AS "balanceBefore", l.balance_after AS "balanceAfter", + l.deposit_amount AS "depositAmount", l.interest_rate AS "interestRate", l.maturity_date AS "maturityDate", + l.payout_savings_account_id AS "payoutSavingsAccountId", l.payout_account_no AS "payoutAccountNo", + l.payout_transaction_id AS "payoutTransactionId", l.fd_transaction_id AS "fdTransactionId" + FROM m_fd_interest_posting_report_line l + WHERE l.report_id = ? + ORDER BY l.interest_posting_date, l.account_no + """, reportId)); + return report; + } + + /** + * What each fixed deposit has earned and not been paid, as at the business date — what it would be + * paid if interest were posted today. Read-only: nothing is saved, approved or posted. + * + *

+ * A branch user sees their own branch; a head office user every branch, or one with officeId. + */ + public List> preview(final Long officeId, final AppUser user) { + final Long userOffice = officeOf(user); + final Long branch = this.branchAuthority.isHeadOffice(userOffice) ? officeId : userOffice; + final List> rows = new ArrayList<>(); + for (final DueLine line : computeDueInterest(DateUtils.getBusinessLocalDate(), branch, null)) { + final Map row = new LinkedHashMap<>(); + row.put("fdAccountId", line.accountId()); + row.put("accountNo", line.accountNo()); + row.put("clientName", line.clientName()); + row.put("officeId", line.officeId()); + row.put("officeName", officeName(line.officeId())); + row.put("productId", line.productId()); + row.put("productName", productName(line.productId())); + row.put("fdType", line.fdType()); + row.put("interestPostingDate", line.postingDate() == null ? null : line.postingDate().toString()); + row.put("depositAmount", line.depositAmount()); + row.put("interestRate", line.interestRate()); + row.put("maturityDate", line.maturityDate() == null ? null : line.maturityDate().toString()); + row.put("balance", line.balanceBefore()); + row.put("interest", line.interest().setScale(MONEY_SCALE, MONEY_ROUNDING)); + row.put("payoutAccountNo", line.payoutAccountNo()); + rows.add(row); + } + return rows; + } + + /** + * Branch and product interest for the head office: per report, how many deposits and how much + * interest, and where it stands. Superseded reports are left out. + */ + 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.fd_product_id AS "productId", p.name AS "productName", r.fd_type AS "fdType", + 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_fd_interest_posting_report r + JOIN m_savings_product p ON p.id = r.fd_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.fd_product_id = ?"); + params.add(productId); + } + sql.append(" ORDER BY r.posting_date DESC, p.name, o.name"); + return this.jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + /** + * Branch and product pairs with deposit interest due by the date that have no report yet — the + * noon job ran before they had anything due, or failed. + */ + 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 = computeDueInterest(postingDate, others ? null : userOffice, null); + for (final Map.Entry> group : groupByProductAndOffice(due).entrySet()) { + final DueKey key = group.getKey(); + if (others == key.officeId().equals(userOffice)) { + continue; + } + if (activeReportId(key.productId(), key.officeId(), postingDate) != null) { + continue; + } + BigDecimal total = BigDecimal.ZERO; + for (final DueLine line : group.getValue()) { + total = total.add(line.interest()); + } + final Map row = new LinkedHashMap<>(); + row.put("productId", key.productId()); + row.put("productName", productName(key.productId())); + row.put("fdType", group.getValue().get(0).fdType()); + row.put("officeId", key.officeId()); + row.put("officeName", officeName(key.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, key.officeId(), businessDate); + row.put("canAct", blocked == null); + row.put("blockedReason", blocked); + out.add(row); + } + return out; + } + + // ── who may act ────────────────────────────────────────────────────────────────────────────── + + /** + * Null where the user may generate or approve the branch's report, otherwise why not. The branch + * rules are the savings ones: a branch always acts for itself, and the head office acts for + * another branch only when that branch has not opened at all and every branch that did open has + * closed. + */ + 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 interest posting 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; + } + + /** @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); + final String blocked = actBlockedReason(userOffice, reportOffice, businessDate); + if (blocked != null) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.other.branch", blocked); + } + return !reportOffice.equals(userOffice); + } + + public boolean isHeadOffice(final Long officeId) { + return this.branchAuthority.isHeadOffice(officeId); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) { + final List ids = this.jdbcTemplate.queryForList(""" + SELECT id FROM m_fd_interest_posting_report + WHERE fd_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 this.jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId); + } + + 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java new file mode 100644 index 000000000..7abcfda5a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java @@ -0,0 +1,477 @@ +/** + * 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.fdinterest; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +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; + +/** + * Pays an approved fixed deposit interest posting report. + * + *

+ * Nothing is recomputed here. The report's lines are the authority: each deposit is credited exactly + * what was approved for it, and 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. + * + *

+ * The ledger entries are the three stages fixed deposit interest has always posted through, raised + * once for the whole report rather than once per deposit — the same pooling the savings interest + * posting report uses, for the same reason: a bank's interest cost is one figure in its books, not + * one line per customer repeated three times over. + * + *

+ *   Dr  Interest Expense          (report total)  →  Cr  Fixed Deposit Interest Payable
+ *   Dr  Interest Payable          (report total)  →  Cr  Fixed Deposit Interest Paid
+ *   Dr  Interest Paid             (report total, once)
+ *   Cr  Savings Control           (per deposit, naming the account and its own amount)
+ * 
+ * + * A product that names no paid account posts the two-stage form, and one that names neither falls + * back to the plain Dr Expense / Cr Control pair — pooled the same way, one expense debit against many + * credits. Where one report holds deposits due for more than one period — a deposit owed two unpaid + * cycles, or a branch posting a stale cycle alongside today's — each {@code interestPostingDate} + * pools on its own: the chain above is raised once per date present on the report, not once overall, + * because the narration and the ledger date belong to the cycle, not to the day it happened to post. + * + *

+ * Where the interest then goes is the product's scheme, and it is not decided here. A + * {@code SPECIAL} deposit carries {@code transfer_interest_to_linked_account}, and the existing + * transfer-interest-to-savings job sweeps the posted interest to the customer's linked savings + * account as it always has; {@code SINGLE} and {@code DOUBLE} keep it in the deposit. Posting is the + * same act for all three — the money reaches the customer's deposit — so this service does one thing + * and the sweep is left where it already works. + * + *

+ * The whole report settles in one transaction: a failure leaves nobody half paid and the report still + * APPROVED for the next attempt. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class FixedDepositInterestPostingService { + + private static final int MONEY_SCALE = 2; + private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + + /** m_savings_account_transaction.transaction_type_enum */ + private static final int INTEREST_POSTING = 3; + /** acc_gl_journal_entry.entity_type_enum for a savings entry. */ + private static final int ENTITY_TYPE_SAVINGS = 2; + /** {@code CashAccountsForSavings.SAVINGS_CONTROL} */ + private static final int GL_SAVINGS_CONTROL = 2; + /** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense interest is charged to. */ + private static final int GL_INTEREST_ON_SAVINGS = 3; + /** {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product. */ + private static final int GL_INTEREST_PAYABLE = 17; + + /** + * The reserved names a deposit product may map its payable and paid accounts under, most specific + * first. The same lists the scheduler reads, so a product configured for the old path needs no + * change for the new one. + */ + private static final String[] PAYABLE_NAMES = { "Fixed Deposit Interest Payable", "Savings Interest Payable", + "Recurring Deposit Interest Payable", "Interest Payable", "Interest Payable Account" }; + private static final String[] PAID_NAMES = { "Fixed Deposit Interest Paid", "Savings Interest Paid", + "Recurring Deposit Interest Paid", "Interest Paid", "Interest Paid Account" }; + + private final JdbcTemplate jdbcTemplate; + + /** The payable and paid accounts a product's interest passes through. */ + public record PostingGlAccounts(Long payableGl, Long paidGl) {} + + /** + * One deposit's credit, after its transaction and balances are written but before the pooled ledger + * legs are raised — grouped with the others of its {@code interestDate} so the whole group is posted + * as one chain. + */ + private record PaidLine(Long accountId, BigDecimal amount, LocalDate interestDate, Long transactionId) {} + + /** + * The payable and paid accounts as the product maps them now, read when a report is generated so a + * product missing one is refused before anybody approves figures that could never be posted. + * + *

+ * Unlike savings, neither is required: a deposit product that names no payable posts the plain two + * legs it always did, and nulls here say so rather than stopping the report. + */ + public PostingGlAccounts resolvePostingGlAccounts(final long productId) { + if (optionalMappedGlAccount(productId, GL_INTEREST_ON_SAVINGS) == null) { + final String productName = this.jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, + productId); + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.gl.not.mapped", + "Fixed deposit product '" + productName + "' has no Interest On Savings GL mapped. Set it under the product's" + + " Accounting and generate the report again."); + } + Long payableGl = optionalMappedGlAccount(productId, GL_INTEREST_PAYABLE); + if (payableGl == null) { + payableGl = namedGlAccount(productId, PAYABLE_NAMES); + } + return new PostingGlAccounts(payableGl, namedGlAccount(productId, PAID_NAMES)); + } + + /** + * Posts one approved report, crediting each deposit exactly what was approved for it. + * + *

+ * 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 date the interest is for. + * + * @return the amount distributed + */ + @Transactional + public BigDecimal postReport(final long reportId) { + final List> reports = this.jdbcTemplate.queryForList(""" + SELECT id, fd_product_id, fd_type, office_id, posting_date, status, total_interest, payable_gl_account_id, + paid_gl_account_id + FROM m_fd_interest_posting_report + WHERE id = ? + FOR UPDATE + """, reportId); + if (reports.isEmpty()) { + throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " does not exist"); + } + final Map report = reports.get(0); + if (!FixedDepositInterestPostingReportService.APPROVED.equals(report.get("status"))) { + throw new IllegalStateException( + "Fixed deposit interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED"); + } + final long productId = ((Number) report.get("fd_product_id")).longValue(); + final long officeId = ((Number) report.get("office_id")).longValue(); + final LocalDate interestFor = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + final LocalDate postedOn = DateUtils.getBusinessLocalDate(); + final BigDecimal approved = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING); + + final List> lines = this.jdbcTemplate.queryForList(""" + SELECT l.id AS line_id, l.fd_account_id, l.interest_posting_date, l.interest_amount, l.fd_transaction_id, + sa.currency_code + FROM m_fd_interest_posting_report_line l + JOIN m_savings_account sa ON sa.id = l.fd_account_id + WHERE l.report_id = ? + ORDER BY l.interest_posting_date, l.fd_account_id + """, reportId); + if (lines.isEmpty()) { + throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " has no deposits to pay"); + } + + final long runId = openRun(reportId, productId, officeId, interestFor, lines.size()); + + final Long expenseGl = mappedGlAccount(productId, GL_INTEREST_ON_SAVINGS); + final Long controlGl = mappedGlAccount(productId, GL_SAVINGS_CONTROL); + 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.FD_INTEREST_POSTED, postedOn, reportId); + + // Each deposit's transaction and balances are settled first; the ledger is raised after, pooled + // by the date its interest belongs to, so a report spanning two cycles raises two chains rather + // than crediting one period's money under the other's date. + final Map> byDate = new LinkedHashMap<>(); + BigDecimal distributed = BigDecimal.ZERO; + for (final Map line : lines) { + final PaidLine paid = payDeposit(line, officeId, postedOn); + if (paid == null) { + continue; + } + byDate.computeIfAbsent(paid.interestDate(), key -> new ArrayList<>()).add(paid); + distributed = distributed.add(paid.amount()); + } + + // If what was paid does not add up to what was approved, nothing commits. + if (approved.compareTo(distributed) != 0) { + throw new IllegalStateException(String.format( + "Fixed deposit interest posting report %d did not balance: approved %s, distributed %s", reportId, approved, + distributed)); + } + + for (final Map.Entry> group : byDate.entrySet()) { + postPooledLedgerLegs(officeId, currency, group.getKey(), postedOn, group.getValue(), reference, expenseGl, payableGl, paidGl, + controlGl); + } + + completeReport(reportId, productId, interestFor, approved, distributed, runId, lines.size()); + return distributed; + } + + /** + * One deposit's interest transaction and balances, settled without raising any ledger entry — the + * caller pools this with the rest of its date-group before the GL is touched. + * + *

+ * The transaction carries the date the interest engine gave the period, not the day the branch is + * closing. That is not presentation: the engine decides a period has already been paid by looking + * for an interest transaction on exactly that date, so a posting written under the close date would + * be invisible to it and the same period would be computed and paid again at the next run. The + * close date is kept as the submitted-on stamp, which is what the scheduler has always done. + * + * @return the deposit's credit, or null for a line with nothing to pay + */ + private PaidLine payDeposit(final Map line, final long officeId, final LocalDate postedOn) { + final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + final long accountId = ((Number) line.get("fd_account_id")).longValue(); + final LocalDate interestDate = ((java.sql.Date) line.get("interest_posting_date")).toLocalDate(); + + // A resumed or duplicated attempt must not pay anybody twice. The line itself records the + // payment, so this holds even where one deposit has two periods on one report. + if (line.get("fd_transaction_id") != null) { + throw new IllegalStateException("Fixed deposit " + accountId + " was already paid by this report"); + } + + // The balance as it stands now, not as the report showed it: money may have moved since the + // report was generated, and the transaction's running balance has to follow the deposit. + final BigDecimal currentBalance = this.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, interestDate, postedOn, amount, newBalance); + + // Earned is only ever raised to what has now been posted, never added to: the engine may + // already have counted this interest as earned, and adding on top of that doubles it. Screens + // read unposted interest as earned - posted, so earned must not fall below posted either. + // + // interest_posted_till_date is what takes the deposit out of the next report's candidates; the + // interest transaction itself is what stops the engine recomputing the same period. + this.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) + ?), + interest_posted_till_date = GREATEST(COALESCE(interest_posted_till_date, ?), ?) + WHERE id = ? + """, amount, amount, amount, interestDate, interestDate, accountId); + + this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", transactionId, + ((Number) line.get("line_id")).longValue()); + + return new PaidLine(accountId, amount, interestDate, transactionId); + } + + /** + * One date-group's chain, pooled: the report's cost moves through the fewer-or-more intermediate + * accounts the product's mappings support as one figure, and only the last leg — crediting each + * deposit — is raised per account. The group's own total is what moves, not the report's, so two + * periods on one report each carry their own entries under their own date. + */ + private void postPooledLedgerLegs(final long officeId, final String currency, final LocalDate interestDate, final LocalDate postedOn, + final List group, final String reference, final Long expenseGl, final Long payableGl, final Long paidGl, + final Long controlGl) { + final String narration = "Fixed deposit interest for " + interestDate; + BigDecimal total = BigDecimal.ZERO; + for (final PaidLine paid : group) { + total = total.add(paid.amount()); + } + + final boolean hasPayable = payableGl != null && payableGl != 0 && !payableGl.equals(controlGl); + final boolean hasPaid = paidGl != null && paidGl != 0 && !paidGl.equals(payableGl); + + // The chain of pooled accounts the group's cost passes through before it is split: Expense, then + // Payable and Paid where the product maps them — each consecutive pair raised once for the whole + // total. The last account in the chain is what Stage 2 debits before crediting each deposit. + final List chain = new ArrayList<>(); + chain.add(expenseGl); + if (hasPayable) { + chain.add(payableGl); + if (hasPaid) { + chain.add(paidGl); + } + } + for (int i = 0; i < chain.size() - 1; i++) { + journalEntry(officeId, currency, chain.get(i), interestDate, postedOn, total, true, reference, null, null, narration); + journalEntry(officeId, currency, chain.get(i + 1), interestDate, postedOn, total, false, reference, null, null, narration); + } + + // Stage 2: one debit for the group's total, and under it a credit to each deposit. 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, chain.get(chain.size() - 1), interestDate, postedOn, total, true, reference, null, null, + narration); + for (final PaidLine paid : group) { + journalEntry(officeId, currency, controlGl, interestDate, postedOn, paid.amount(), false, reference, paid.accountId(), + paid.transactionId(), narration); + } + } + + private void completeReport(final long reportId, final long productId, final LocalDate interestFor, final BigDecimal approved, + final BigDecimal distributed, final long runId, final int accountCount) { + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_run + SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0, + status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, approved, distributed, runId); + this.jdbcTemplate.update(""" + UPDATE m_fd_interest_posting_report + SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL + WHERE id = ? + """, runId, reportId); + log.info("FD interest posting: report {} (product {}, for {}) paid {} across {} deposits", reportId, productId, interestFor, + distributed, 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 postingDate, + final int accountCount) { + this.jdbcTemplate.update(""" + INSERT INTO m_fd_interest_posting_run + (report_id, fd_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 + """, reportId, productId, officeId, postingDate, accountCount); + return this.jdbcTemplate.queryForObject("SELECT id FROM m_fd_interest_posting_run WHERE report_id = ?", Long.class, reportId); + } + + /** + * @param interestDate + * the period the interest is for, which is the transaction's own date + * @param postedOn + * the business date it is being written on, kept as the submitted-on stamp + */ + private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate interestDate, + final LocalDate postedOn, final BigDecimal amount, final BigDecimal runningBalance) { + this.jdbcTemplate.update(""" + INSERT INTO m_savings_account_transaction + (savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction, + transaction_type_enum, transaction_date, amount, running_balance_derived, + submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc) + VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, accountId, officeId, INTEREST_POSTING, interestDate, amount, runningBalance, postedOn, systemUserId(), + systemUserId()); + return this.jdbcTemplate.queryForObject(""" + SELECT id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ? + ORDER BY id DESC LIMIT 1 + """, Long.class, accountId, interestDate, INTEREST_POSTING); + } + + /** + * One journal entry leg. Fineract's {@code type_enum} is 2 = DEBIT, 1 = CREDIT — {@code + * JournalEntryType} declares {@code CREDIT(1)} first. + * + *

+ * Dated the period the interest is for, as the scheduler dates its own legs, with the business date + * kept as the submitted-on stamp. + */ + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate interestDate, + final LocalDate postedOn, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, + final Long transactionId, final String narration) { + GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId); + this.jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, + amount, description, entity_type_enum, entity_id, is_running_balance_calculated, + savings_transaction_id, reversed, manual_entry, office_running_balance, + organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date, + 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, interestDate, interestDate, debit ? 2 : 1, amount, narration, + savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(), + postedOn); + } + + /** + * The user these rows are attributed to. A day close has a user, but the nightly retry does not and + * the audit columns are NOT NULL, so entries are stamped to the system account either way — as the + * scheduler's own postings always were. + */ + private Long systemUserId() { + final List ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", + Long.class); + return ids.isEmpty() ? 1L : ids.get(0); + } + + private Long mappedGlAccount(final long productId, final int financialAccountType) { + final Long found = optionalMappedGlAccount(productId, financialAccountType); + if (found == null) { + throw new IllegalStateException("Fixed deposit product " + productId + " has no GL account mapped for financial account type " + + financialAccountType + ". Set the product's Accounting tab before its interest can be posted."); + } + return found; + } + + private Long optionalMappedGlAccount(final long productId, final int financialAccountType) { + final List found = this.jdbcTemplate.queryForList(""" + SELECT gl_account_id FROM acc_product_mapping + WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? + LIMIT 1 + """, Long.class, productId, financialAccountType); + return found.isEmpty() ? null : found.get(0); + } + + /** + * The GL account a product maps under one of the reserved names in {@code other_gl_mappings}, tried + * in order of preference. The column holds an empty string on a product saved with no named + * mappings and casting that to jsonb is an error, so the cast sits inside a subquery that has + * already filtered those rows out. + */ + private Long namedGlAccount(final long productId, final String... accountNames) { + for (final String accountName : accountNames) { + final List found = this.jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?)) + LIMIT 1 + """, Long.class, productId, accountName); + if (!found.isEmpty()) { + return found.get(0); + } + } + return null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java new file mode 100644 index 000000000..d58b53ead --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java @@ -0,0 +1,229 @@ +/** + * 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.fdinterest.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.fdinterest.FixedDepositInterestPostingReportService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +/** + * The fixed deposit 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 interest reaches a customer's deposit. + */ +@Path("/v1/fixeddepositinterestpostingreports") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class FixedDepositInterestPostingReportApiResource { + + private static final String RESOURCE = "FDINTERESTREPORT"; + + private final FixedDepositInterestPostingReportService reportService; + private final ConfigurationDomainService configurationDomainService; + 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, + @QueryParam("scope") final String scope) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return this.gson + .toJson(this.reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream() + .map(this::plain).toList()); + } + + /** + * Who is looking: their branch, whether it is the head office (which gets the Other branches tab), + * and whether deposits are on the approval path at all — with the switch off every screen here is + * empty by design, and the UI should say so rather than look broken. + */ + @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.isFixedDepositInterestPostingApprovalEnabled()); + return this.gson.toJson(result); + } + + /** Branch and product pairs with deposit interest due 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)); + } + + /** + * What stops a branch closing the business day: its own deposit reports due and not yet approved, + * and the products and amounts behind them. 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 = 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.dayCloseInterestView(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); + } + + /** What each deposit has earned and not been paid, as if posted today. View only — nothing is saved. */ + @GET + @Path("/preview") + public String preview(@QueryParam("officeId") final Long officeId) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return this.gson.toJson(this.reportService.preview(officeId, user).stream().map(this::plain).toList()); + } + + /** The head office's view: per branch and product, deposits 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 = this.context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + if (!this.reportService.isHeadOffice(user.getOffice().getId())) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.summary.head.office.only", + "The branch-wise fixed deposit interest summary is for the head office."); + } + return this.gson + .toJson(this.reportService.summary(parseDate(fromDate), parseDate(toDate), productId).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 {"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.fdinterestreport.unknown.command", "Unsupported command: " + command); + } + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("REGENERATE_" + RESOURCE); + final var root = this.fromJsonHelper.parse(body); + final Long productId = this.fromJsonHelper.extractLongNamed("productId", root); + final Long officeIdParam = this.fromJsonHelper.extractLongNamed("officeId", root); + final String postingDate = this.fromJsonHelper.extractStringNamed("postingDate", root); + if (productId == null) { + throw new PlatformDataIntegrityException("error.msg.fdinterestreport.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 = this.reportService.regenerate(productId, officeId, date, user); + return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.GENERATED)); + } + + /** + * {@code ?command=approve}. A user of the report's branch who did not generate it 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.fdinterestreport.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", FixedDepositInterestPostingReportService.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.fdinterestreport.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/fdinterest/jobs/FixedDepositInterestPostingReportConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportConfig.java new file mode 100644 index 000000000..2c1a812cd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportConfig.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.fdinterest.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 FixedDepositInterestPostingReportConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step fdInterestPostingReportStep(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) { + return new StepBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository) + .tasklet(fdInterestPostingReportTasklet, transactionManager).build(); + } + + @Bean + public Job fdInterestPostingReportJob(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) { + return new JobBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository) + .start(fdInterestPostingReportStep(fdInterestPostingReportTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportTasklet.java new file mode 100644 index 000000000..7f5bb3fba --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/jobs/FixedDepositInterestPostingReportTasklet.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.fdinterest.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.fdinterest.FixedDepositInterestPostingReportService; +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 fixed deposit product + * whose interest falls due, so a branch can review and approve it before it closes the day. + * + *

+ * Does nothing while deposits are off the approval path — the posting job is paying them directly, + * and a report generated alongside it would be approved against money that has already moved. + */ +@RequiredArgsConstructor +@Component +@Slf4j +public class FixedDepositInterestPostingReportTasklet implements Tasklet { + + private final FixedDepositInterestPostingReportService reportService; + private final ConfigurationDomainService configurationDomainService; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) throws Exception { + if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) { + log.info("FD interest posting report: skipped — fixed deposit interest is posted directly by the posting 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/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index b6c0ce636..80d8e6e2c 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 @@ -36,6 +36,7 @@ import java.util.List; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.common.AccountingRuleType; import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.data.EnumOptionData; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.domain.JdbcSupport; @@ -109,16 +110,24 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; private final SavingsDailyInterestService savingsDailyInterestService; + private final ConfigurationDomainService configurationDomainService; + + /** m_savings_account.status_enum for an active account. */ + private static final Integer ACTIVE_STATUS = 300; + /** m_savings_product.deposit_type_enum for a fixed deposit. */ + private static final Integer DEPOSIT_TYPE_FIXED = 200; public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, - final SavingsDailyInterestService savingsDailyInterestService) { + final SavingsDailyInterestService savingsDailyInterestService, + final ConfigurationDomainService configurationDomainService) { this.context = context; this.jdbcTemplate = jdbcTemplate; this.sqlGenerator = sqlGenerator; this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; this.savingsDailyInterestService = savingsDailyInterestService; + this.configurationDomainService = configurationDomainService; this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); this.transactionsMapper = new SavingsAccountTransactionsMapper(); this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); @@ -440,10 +449,19 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead // (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. + // + // Fixed deposits come out on the same grounds once their interest goes through an approved + // report (FixedDepositInterestPostingReportService): the report's figures are posted when the + // branch closes its day, and this job running over the same deposits would pay them twice. + // Recurring deposits are not part of that gate and still post here either way. + final String fixedDepositFilter = this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled() + ? "and a.deposit_type_enum <> " + DEPOSIT_TYPE_FIXED + " " + : ""; String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + "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 "; + + "where s.savings_product_id = a.product_id and s.is_active = true) " + fixedDepositFilter + + "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) "; @@ -461,6 +479,55 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead return savingsAccountDataList; } + /** + * The fixed deposits of one branch, and optionally one product, whose interest may be due on the + * posting date — the candidates an interest posting report is drawn from. + * + *

+ * "May be" is deliberate. A deposit is selected here on the same cheap test the posting job has + * always used, that its interest has not been posted past the day before, and whether a posting + * period has actually ended is then settled by the interest engine, which is the only thing that + * knows a product's compounding and posting periods. A candidate the engine finds nothing for + * simply contributes no line to the report. + * + *

+ * Unpaged, unlike the posting job: a report covers one branch and one product and is read in full + * before anybody approves it, so there is no page for a deposit to fall off the end of. + */ + @Override + public List retrieveFixedDepositsDueForInterestPosting(final boolean backdatedTxnsAllowedTill, + final LocalDate postingDate, final Long officeId, final Long productId) { + final StringBuilder candidates = new StringBuilder("select a.id from m_savings_account a ") + .append("left join m_client ic on ic.id = a.client_id ") + .append("left join m_group ig on ig.id = a.group_id ") + .append("where a.status_enum = ? and a.deposit_type_enum = ? ") + .append("and (a.interest_posted_till_date is null or a.interest_posted_till_date <= ?) "); + final List params = new ArrayList<>(List.of(ACTIVE_STATUS, DEPOSIT_TYPE_FIXED, postingDate.minusDays(1))); + if (officeId != null) { + candidates.append("and coalesce(ic.office_id, ig.office_id) = ? "); + params.add(officeId); + } + if (productId != null) { + candidates.append("and a.product_id = ? "); + params.add(productId); + } + + String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + "join (" + candidates + + ") 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) "; + } + sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id"; + + final List deposits = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR + params.toArray()); + for (final SavingsAccountData deposit : deposits) { + this.savingAccountAssembler.assembleSavings(deposit); + } + return deposits; + } + private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor> { private final String schemaSql; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 976d2e6ce..ec25afd78 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -430,9 +430,9 @@ public class SavingsConfiguration { SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator, // @Lazy because the accrual service depends on beans from this same package: resolving // both eagerly closes a cycle and the context fails to start. - @Lazy SavingsDailyInterestService savingsDailyInterestService) { + @Lazy SavingsDailyInterestService savingsDailyInterestService, ConfigurationDomainService configurationDomainService) { return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator, - sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService); + sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService, configurationDomainService); } @Bean 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 0f961c93b..470c64207 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 @@ -165,4 +165,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml new file mode 100644 index 000000000..79791c1ab --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Generate Fixed Deposit Interest Posting Report' + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_FDINTERESTREPORT', + 'REGENERATE_FDINTERESTREPORT', 'APPROVE_FDINTERESTREPORT') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_FDINTERESTREPORT', 'REGENERATE_FDINTERESTREPORT', + 'APPROVE_FDINTERESTREPORT') + + + + + 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_FDINTERESTREPORT', 'REGENERATE_FDINTERESTREPORT', + 'APPROVE_FDINTERESTREPORT') + 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-interest-posting-approval' + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java index cf408c534..dc2229636 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformService.java @@ -68,6 +68,18 @@ public interface SavingsAccountReadPlatformService { List retrieveAllSavingsDataForInterestPosting(boolean backdatedTxnsAllowedTill, int pageSize, Integer status, Long maxSavingsId); + /** + * The active fixed deposits of one branch — and one product, where given — whose interest may be + * due for posting on the date. The interest engine settles whether any actually is. + * + * @param officeId + * the branch the deposit's customer belongs to, or null for every branch + * @param productId + * one fixed deposit product, or null for every product + */ + List retrieveFixedDepositsDueForInterestPosting(boolean backdatedTxnsAllowedTill, LocalDate postingDate, + Long officeId, Long productId); + List retrieveAllTransactionData(List refNo); Long retrieveAccountIdByExternalId(ExternalId externalId); From 6fbc3a85a8e216ffaa4df983adb94650d33ccca4 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 8 Oct 2026 00:06:14 +0530 Subject: [PATCH 2/7] feat(fixed-deposits): show the period each posting covers, surface maturity postings, guard against double-posting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds interest_period_from to the report line so each posting shows the span it covers (e.g. "01 Oct - 31 Dec, Quarterly"), not just the date it ends on, and a postingPeriodLabel on the report itself. Adds a reconciliation endpoint (outside-approval) that lists fixed deposit interest posted without this report's approval — chiefly a deposit that matured before its own posting cycle completed, whose last period the maturity job settles directly. Read-only, so a branch can see every rupee of deposit interest it has paid, approved or not. Guards posting itself against the same situation: before crediting a line, checks whether its deposit already carries an interest posting for that exact date (raised by the maturity job in the window between this report being generated and being posted). If so, the line is linked to what actually paid it instead of being paid again, and its amount is reconciled off the report's approved total rather than failing the whole posting. Co-Authored-By: Claude Sonnet 5 --- ...edDepositInterestPostingReportService.java | 144 ++++++++++++++++-- .../FixedDepositInterestPostingService.java | 84 +++++++--- ...positInterestPostingReportApiResource.java | 16 ++ .../0593_fd_interest_posting_approval.xml | 15 ++ 4 files changed, 229 insertions(+), 30 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java index 2dc8695f2..486c5df0f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingReportService.java @@ -82,6 +82,18 @@ public class FixedDepositInterestPostingReportService { private static final int MONEY_SCALE = 2; private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + /** + * The product's posting cycle in words, read off {@code SavingsPostingInterestPeriodType}'s own + * values (1 daily, 4 monthly, 5 quarterly, 6 biannual, 7 annual) so a checker reading a period range + * also reads what kind of period it is, not just its two dates. + */ + private static final String POSTING_PERIOD_LABEL_CASE = """ + CASE p.interest_posting_period_enum + WHEN 1 THEN 'Daily' WHEN 4 THEN 'Monthly' WHEN 5 THEN 'Quarterly' + WHEN 6 THEN 'Half-yearly' WHEN 7 THEN 'Yearly' ELSE 'Interest' + END + """; + private final JdbcTemplate jdbcTemplate; private final SavingsAccountReadPlatformService savingsAccountReadPlatformService; private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; @@ -120,7 +132,7 @@ public class FixedDepositInterestPostingReportService { */ public record DueLine(Long accountId, String accountNo, String clientName, Long officeId, Long productId, String fdType, LocalDate postingDate, BigDecimal interest, BigDecimal balanceBefore, BigDecimal depositAmount, BigDecimal interestRate, - LocalDate maturityDate, Long payoutSavingsAccountId, String payoutAccountNo) {} + LocalDate maturityDate, Long payoutSavingsAccountId, String payoutAccountNo, LocalDate periodFrom) {} /** One unpaid posting the engine worked out: the date it belongs to and what it comes to. */ private record ComputedPosting(Long accountId, LocalDate postingDate, BigDecimal interest) {} @@ -219,7 +231,7 @@ public class FixedDepositInterestPostingReportService { final String ids = postings.stream().map(ComputedPosting::accountId).distinct().map(String::valueOf) .collect(Collectors.joining(",")); final List> rows = this.jdbcTemplate.queryForList(""" - SELECT sa.id AS account_id, sa.account_no, sa.product_id, + SELECT sa.id AS account_id, sa.account_no, sa.product_id, sa.activatedon_date, COALESCE(c.display_name, g.display_name) AS client_name, COALESCE(c.office_id, g.office_id) AS office_id, COALESCE(sa.account_balance_derived, 0) AS balance, @@ -242,8 +254,19 @@ public class FixedDepositInterestPostingReportService { byAccount.put(((Number) row.get("account_id")).longValue(), row); } + // Where each deposit's last paid period ended, so the new one's own period can be shown + // starting the day after — not computed from the posting itself, which only carries the day + // the period ends. A deposit with no interest posted yet starts counting from its activation. + final Map lastPaidThrough = previousPeriodEnds(byAccount.keySet(), postings); + + // Earliest date first within each deposit, so a deposit owed two periods on this report has + // its second period's "from" follow the first's own "to" rather than both reading the same + // opening date. + final List ordered = postings.stream() + .sorted(Comparator.comparing(ComputedPosting::accountId).thenComparing(ComputedPosting::postingDate)).toList(); + final List lines = new ArrayList<>(); - for (final ComputedPosting posting : postings) { + for (final ComputedPosting posting : ordered) { final Map row = byAccount.get(posting.accountId()); if (row == null) { continue; @@ -259,16 +282,49 @@ public class FixedDepositInterestPostingReportService { // names an account to pay into. final boolean paysOut = fdType.isPaidOutMonthly() && Boolean.TRUE.equals(row.get("transfer_interest_to_linked_account")); final Object payoutId = paysOut ? row.get("transfer_to_savings_account_id") : null; + final LocalDate periodStart = lastPaidThrough.get(posting.accountId()); + final LocalDate periodFrom = periodStart == null ? toLocalDate(row.get("activatedon_date")) : periodStart.plusDays(1); + lastPaidThrough.put(posting.accountId(), posting.postingDate()); lines.add(new DueLine(posting.accountId(), (String) row.get("account_no"), (String) row.get("client_name"), ((Number) officeId).longValue(), ((Number) row.get("product_id")).longValue(), fdType.name(), posting.postingDate(), posting.interest(), (BigDecimal) row.get("balance"), (BigDecimal) row.get("deposit_amount"), (BigDecimal) row.get("interest_rate"), toLocalDate(row.get("maturity_date")), payoutId == null ? null : ((Number) payoutId).longValue(), - paysOut ? (String) row.get("payout_account_no") : null)); + paysOut ? (String) row.get("payout_account_no") : null, periodFrom)); } return lines; } + /** + * Each deposit's most recent posted interest, as at the earliest of its postings being reported + * here — null for a deposit with none, which starts its first period from activation instead. + */ + private Map previousPeriodEnds(final Set accountIds, final List postings) { + if (accountIds.isEmpty()) { + return new LinkedHashMap<>(); + } + final Map earliestNew = new LinkedHashMap<>(); + for (final ComputedPosting posting : postings) { + earliestNew.merge(posting.accountId(), posting.postingDate(), (a, b) -> a.isBefore(b) ? a : b); + } + final String ids = accountIds.stream().map(String::valueOf).collect(Collectors.joining(",")); + final Map result = new LinkedHashMap<>(); + for (final Map row : this.jdbcTemplate.queryForList(""" + SELECT savings_account_id, MAX(transaction_date) AS last_date + FROM m_savings_account_transaction + WHERE savings_account_id IN (%s) AND transaction_type_enum = 3 AND is_reversed = false + GROUP BY savings_account_id + """.formatted(ids))) { + final Long accountId = ((Number) row.get("savings_account_id")).longValue(); + final LocalDate lastDate = toLocalDate(row.get("last_date")); + final LocalDate newEarliest = earliestNew.get(accountId); + if (lastDate != null && (newEarliest == null || lastDate.isBefore(newEarliest))) { + result.put(accountId, lastDate); + } + } + return result; + } + private static LocalDate toLocalDate(final Object value) { return value instanceof java.sql.Date date ? date.toLocalDate() : null; } @@ -467,11 +523,11 @@ public class FixedDepositInterestPostingReportService { INSERT INTO m_fd_interest_posting_report_line (report_id, fd_account_id, account_no, client_name, office_id, interest_posting_date, interest_amount, balance_before, balance_after, deposit_amount, interest_rate, maturity_date, payout_savings_account_id, - payout_account_no) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + payout_account_no, interest_period_from) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, reportId, line.accountId(), line.accountNo(), line.clientName(), line.officeId(), line.postingDate(), interest, before, after, line.depositAmount(), line.interestRate(), line.maturityDate(), line.payoutSavingsAccountId(), - line.payoutAccountNo()); + line.payoutAccountNo(), line.periodFrom()); } log.info("FD interest report {}: product {} ({}) branch {} on {} ({}) — {} across {} posting(s)", reportId, productId, fdType, @@ -624,14 +680,15 @@ public class FixedDepositInterestPostingReportService { 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" + r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason", + %s AS "postingPeriodLabel" FROM m_fd_interest_posting_report r JOIN m_savings_product p ON p.id = r.fd_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 - """); + """.formatted(POSTING_PERIOD_LABEL_CASE)); final List params = new ArrayList<>(); if (postingDate != null) { sql.append(" AND r.posting_date = ?"); @@ -687,7 +744,8 @@ public class FixedDepositInterestPostingReportService { 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" + dg.gl_code AS "paidGlCode", dg.name AS "paidGlName", + %s AS "postingPeriodLabel" FROM m_fd_interest_posting_report r JOIN m_savings_product p ON p.id = r.fd_product_id LEFT JOIN m_office o ON o.id = r.office_id @@ -696,7 +754,7 @@ public class FixedDepositInterestPostingReportService { 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 WHERE r.id = ? - """, reportId); + """.formatted(POSTING_PERIOD_LABEL_CASE), reportId); if (found.isEmpty()) { throw new PlatformDataIntegrityException("error.msg.fdinterestreport.not.found", "Fixed deposit interest posting report " + reportId + " does not exist."); @@ -704,6 +762,7 @@ public class FixedDepositInterestPostingReportService { 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.interest_period_from AS "interestPeriodFrom", l.interest_posting_date AS "interestPostingDate", l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore", l.balance_after AS "balanceAfter", l.deposit_amount AS "depositAmount", l.interest_rate AS "interestRate", l.maturity_date AS "maturityDate", @@ -737,6 +796,7 @@ public class FixedDepositInterestPostingReportService { row.put("productId", line.productId()); row.put("productName", productName(line.productId())); row.put("fdType", line.fdType()); + row.put("interestPeriodFrom", line.periodFrom() == null ? null : line.periodFrom().toString()); row.put("interestPostingDate", line.postingDate() == null ? null : line.postingDate().toString()); row.put("depositAmount", line.depositAmount()); row.put("interestRate", line.interestRate()); @@ -759,13 +819,14 @@ public class FixedDepositInterestPostingReportService { r.fd_product_id AS "productId", p.name AS "productName", r.fd_type AS "fdType", 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" + r.approved_at AS "approvedAt", r.posted_at AS "postedAt", + %s AS "postingPeriodLabel" FROM m_fd_interest_posting_report r JOIN m_savings_product p ON p.id = r.fd_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' - """); + """.formatted(POSTING_PERIOD_LABEL_CASE)); final List params = new ArrayList<>(); if (fromDate != null) { sql.append(" AND r.posting_date >= ?"); @@ -825,6 +886,63 @@ public class FixedDepositInterestPostingReportService { return out; } + /** + * Fixed deposit interest posted without ever going through this report — chiefly a deposit maturing + * before its own posting cycle completed, whose last (and only its last) period is settled directly + * by the maturity job rather than reported and approved. Read-only, for visibility: a branch should + * be able to see every rupee of deposit interest it has paid, not only the part that was approved. + * + *

+ * Found by what it is NOT, rather than by naming the maturity job specifically: any interest + * posting transaction with no report line pointing at it. That also catches a posting from any + * other direct path this or a future change might open, rather than a list that quietly goes stale + * the day a second such path appears. + * + * @param officeId + * one branch, or null for every branch (or every branch's own, under scope) + */ + public List> outsideApproval(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 sat.id AS "transactionId", 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", + sat.transaction_date AS "interestFor", sat.amount AS "interestAmount", + sat.created_on_utc AS "postedAt" + FROM m_savings_account_transaction sat + JOIN m_savings_account sa ON sa.id = sat.savings_account_id + JOIN m_savings_product sp ON sp.id = sa.product_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 sat.transaction_type_enum = 3 AND sa.deposit_type_enum = 200 AND sat.is_reversed = false + AND NOT EXISTS (SELECT 1 FROM m_fd_interest_posting_report_line l WHERE l.fd_transaction_id = sat.id) + """); + final List params = new ArrayList<>(); + if (fromDate != null) { + sql.append(" AND sat.transaction_date >= ?"); + params.add(fromDate); + } + if (toDate != null) { + sql.append(" AND sat.transaction_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 sat.transaction_date DESC, sa.account_no"); + return this.jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + // ── who may act ────────────────────────────────────────────────────────────────────────────── /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java index 7abcfda5a..aef8786e5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java @@ -74,6 +74,15 @@ import org.springframework.transaction.annotation.Transactional; * and the sweep is left where it already works. * *

+ * A deposit can mature between its report being generated and being posted, and a matured deposit's + * last period is settled directly by the maturity job the moment it matures — see + * {@code FixedDepositAccount#postMaturityInterest}, which this report's generation step cannot see + * coming and does not block on. Posting checks for that first: a line whose deposit already carries an + * interest posting for its exact date is linked to what actually paid it rather than paid again, and + * its amount is reconciled off the report's approved total rather than failing the whole posting over + * money that was never going to move twice. + * + *

* The whole report settles in one transaction: a failure leaves nobody half paid and the report still * APPROVED for the next attempt. */ @@ -118,6 +127,16 @@ public class FixedDepositInterestPostingService { */ private record PaidLine(Long accountId, BigDecimal amount, LocalDate interestDate, Long transactionId) {} + /** + * What one line's posting came to: either a new credit to pool with the rest of its date-group, or + * an amount that turned out to be settled already — a deposit that matured, and had this exact + * period's interest posted directly by the maturity job, between this report being generated and + * being posted. {@code paid} is null in that case, and {@code reconciledAway} carries the amount so + * the report's approved total can be reconciled down by it rather than the whole posting failing a + * balance check over money that was never going to move twice. + */ + private record DepositOutcome(PaidLine paid, BigDecimal reconciledAway) {} + /** * The payable and paid accounts as the product maps them now, read when a report is generated so a * product missing one is refused before anybody approves figures that could never be posted. @@ -206,20 +225,25 @@ public class FixedDepositInterestPostingService { // than crediting one period's money under the other's date. final Map> byDate = new LinkedHashMap<>(); BigDecimal distributed = BigDecimal.ZERO; + BigDecimal reconciledAway = BigDecimal.ZERO; for (final Map line : lines) { - final PaidLine paid = payDeposit(line, officeId, postedOn); - if (paid == null) { - continue; + final DepositOutcome outcome = payDeposit(line, officeId, postedOn); + if (outcome.paid() != null) { + byDate.computeIfAbsent(outcome.paid().interestDate(), key -> new ArrayList<>()).add(outcome.paid()); + distributed = distributed.add(outcome.paid().amount()); } - byDate.computeIfAbsent(paid.interestDate(), key -> new ArrayList<>()).add(paid); - distributed = distributed.add(paid.amount()); + reconciledAway = reconciledAway.add(outcome.reconciledAway()); } - // If what was paid does not add up to what was approved, nothing commits. - if (approved.compareTo(distributed) != 0) { - throw new IllegalStateException(String.format( - "Fixed deposit interest posting report %d did not balance: approved %s, distributed %s", reportId, approved, - distributed)); + // A line reconciled away was never going to move twice: its amount is taken off what this + // posting is expected to distribute rather than failing the whole report over money that is + // already in the customer's deposit by another, older path. What was actually paid here still + // has to add up exactly to what is left. + final BigDecimal expected = approved.subtract(reconciledAway); + if (expected.compareTo(distributed) != 0) { + throw new IllegalStateException( + String.format("Fixed deposit interest posting report %d did not balance: approved %s, reconciled away %s," + + " distributed %s", reportId, approved, reconciledAway, distributed)); } for (final Map.Entry> group : byDate.entrySet()) { @@ -242,14 +266,20 @@ public class FixedDepositInterestPostingService { * be invisible to it and the same period would be computed and paid again at the next run. The * close date is kept as the submitted-on stamp, which is what the scheduler has always done. * - * @return the deposit's credit, or null for a line with nothing to pay + *

+ * Checked first against the deposit's own transactions for one this report did not put there: a + * deposit can mature between its report being generated and being posted, and the maturity job + * settles a matured deposit's last period itself, directly, the moment it matures — see + * {@code FixedDepositAccount#postMaturityInterest}. Where that has already happened for this exact + * date, the line is linked to what actually paid it rather than paying it again. */ - private PaidLine payDeposit(final Map line, final long officeId, final LocalDate postedOn) { + private DepositOutcome payDeposit(final Map line, final long officeId, final LocalDate postedOn) { final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING); - if (amount.compareTo(BigDecimal.ZERO) <= 0) { - return null; - } final long accountId = ((Number) line.get("fd_account_id")).longValue(); + final long lineId = ((Number) line.get("line_id")).longValue(); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return new DepositOutcome(null, BigDecimal.ZERO); + } final LocalDate interestDate = ((java.sql.Date) line.get("interest_posting_date")).toLocalDate(); // A resumed or duplicated attempt must not pay anybody twice. The line itself records the @@ -258,6 +288,16 @@ public class FixedDepositInterestPostingService { throw new IllegalStateException("Fixed deposit " + accountId + " was already paid by this report"); } + final Long existing = findExistingInterestPosting(accountId, interestDate); + if (existing != null) { + log.warn( + "FD interest report line {}: deposit {} already has an interest posting for {} (transaction {}) not raised by this" + + " report — most likely matured in the meantime. Linking to it instead of posting again.", + lineId, accountId, interestDate, existing); + this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", existing, lineId); + return new DepositOutcome(null, amount); + } + // The balance as it stands now, not as the report showed it: money may have moved since the // report was generated, and the transaction's running balance has to follow the deposit. final BigDecimal currentBalance = this.jdbcTemplate.queryForObject( @@ -283,9 +323,19 @@ public class FixedDepositInterestPostingService { """, amount, amount, amount, interestDate, interestDate, accountId); this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", transactionId, - ((Number) line.get("line_id")).longValue()); + lineId); - return new PaidLine(accountId, amount, interestDate, transactionId); + return new DepositOutcome(new PaidLine(accountId, amount, interestDate, transactionId), BigDecimal.ZERO); + } + + /** A non-reversed interest posting this deposit already carries for this exact date, if any. */ + private Long findExistingInterestPosting(final long accountId, final LocalDate interestDate) { + final List found = this.jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_type_enum = ? AND transaction_date = ? AND is_reversed = false + ORDER BY id DESC LIMIT 1 + """, Long.class, accountId, INTEREST_POSTING, interestDate); + return found.isEmpty() ? null : found.get(0); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java index d58b53ead..70d90e6ec 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/api/FixedDepositInterestPostingReportApiResource.java @@ -150,6 +150,22 @@ public class FixedDepositInterestPostingReportApiResource { .toJson(this.reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList()); } + /** + * Deposit interest posted without this report ever seeing it — chiefly a deposit maturing before + * its posting cycle completed, whose last period a different, older path settles directly. Here so + * a branch can see every rupee of deposit interest it has paid, approved or not. + */ + @GET + @Path("/outside-approval") + public String outsideApproval(@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.outsideApproval(parseDate(fromDate), parseDate(toDate), officeId, user, scope).stream() + .map(this::plain).toList()); + } + @GET @Path("/{reportId}") public String detail(@PathParam("reportId") final Long reportId) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml index 79791c1ab..301180bf0 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_fd_interest_posting_approval.xml @@ -317,6 +317,21 @@ new path can return to the old one without a code change, and so the two never run at once — every query that selects deposits for posting reads this same flag. --> + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'fd-interest-posting-approval' From 6e7eb947766b463d4ce094a57de16a8c438ecb4a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 8 Oct 2026 19:03:17 +0530 Subject: [PATCH 3/7] fix(users): encode password and email it when approving a pending user creation finalizeCreateUser() (run when a checker approves a CREATE_USER request queued in m_user_pending_request) saved AppUser.fromJson()'s result straight to m_appuser via appUserRepository.saveAndFlush() with no encoding step for the password AppUser.fromJson() auto-generates when the maker ticks "send password to email" -- AppUser's constructor stores whatever string it is given verbatim, so that random password was landing in m_appuser.password as plain text. Only the maker-chosen-password case (request.getPasswordHash() != null) was ever hashed, because that happens earlier, at submitCreateUser() time. It also never called the email service at all, in either case. The live create-user flow (JpaUserDomainService.create()) captures the unencoded password, encodes it, saves, then emails the welcome message -- finalizeCreateUser() duplicates everything up to the save but stops there, so a user created through this maker-checker flow never received their credentials regardless of what sendPasswordToEmail said in the submitted payload. Fix: capture the auto-generated plaintext right after AppUser.fromJson(), encode it with this class's own PasswordEncoder bean (already used the same way for the maker-chosen-password branch, consistent within the file) before saving, and call PlatformEmailService.sendToUserAccount() afterwards when sendPasswordToEmail was true and no maker-chosen password overrides it. A maker-chosen password is unrecoverable by approval time (submitCreateUser() redacts it to "[PROTECTED]" immediately), so that case still cannot be emailed, and isn't -- there is nothing wrong to fix there. Found by manually submitting a CREATE_USER request locally and reading both AppUser.fromJson() and the approval path end to end; no existing approved user was re-created to verify in place, since doing so would need resetting a real password. Co-Authored-By: Claude Opus 5 (1M context) --- .../UserPendingWritePlatformServiceImpl.java | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java index 16d140cb1..f23767e00 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java @@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRu import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; +import org.apache.fineract.infrastructure.core.service.PlatformEmailService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; @@ -82,6 +83,7 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat private final PasswordEncoder passwordEncoder; private final UserDataValidator fromApiJsonDeserializer; private final FromJsonHelper fromApiJsonHelper; + private final PlatformEmailService emailService; @Override @Transactional @@ -293,13 +295,40 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat appUser.updatePasswordResetRequired(true); } - // Set pre-hashed password + // AppUser.fromJson() already generated a random plaintext password here when the maker ticked + // "send password to email" (it ignores whatever password the maker typed in that case) -- the + // field still holds that plaintext at this point, since nothing has encoded it yet. If the + // maker instead supplied their own password, request.getPasswordHash() carries the bcrypt + // hash submitCreateUser() produced from it; that plaintext was redacted to "[PROTECTED]" + // immediately and is gone for good, so there is nothing to email in that case. + final boolean sendPasswordToEmail = Boolean.TRUE.equals(replay.booleanObjectValueOfParameterNamed("sendPasswordToEmail")); + final String unencodedPassword = appUser.getPassword(); + if (request.getPasswordHash() != null) { + // Maker-chosen password: already hashed, used as-is. appUser.updatePassword(request.getPasswordHash()); + } else { + // Auto-generated password (the normal "send password to email" case): this service's own + // generic PasswordEncoder, not the salted PlatformPasswordEncoder the live create-user flow + // uses -- consistent with how the maker-chosen-password branch above and submitCreateUser() + // already hash a password in this file. Encoding it here is not optional: AppUser's + // constructor stores whatever string it is given verbatim (see AppUser(Office, User, ...)), + // so skipping this would persist the plaintext password straight into m_appuser.password. + appUser.updatePassword(this.passwordEncoder.encode(unencodedPassword)); } this.appUserRepository.saveAndFlush(appUser); + // Mirrors JpaUserDomainService.create(): the welcome email the live create-user flow sends is + // the one thing this parallel, pending-request flow never did -- sendPasswordToEmail was + // accepted and stored in the payload but nothing here ever acted on it, so a user created this + // way never received their password. Only fires for the auto-generated password: there is no + // plaintext to send for a maker-chosen one, per the comment above. + if (sendPasswordToEmail && request.getPasswordHash() == null) { + this.emailService.sendToUserAccount(userOffice.getName(), appUser.getFirstname(), appUser.getEmail(), appUser.getUsername(), + unencodedPassword); + } + // MFA Enrollment final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod"); if (otpDeliveryMethod != null) { From 1a914a3de088ac73255890607d0915b1ff093bd8 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 8 Oct 2026 19:06:40 +0530 Subject: [PATCH 4/7] 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' + + + + + + + + + + + + From 3d3ce92e938b437fc6003aaeb86627b416cc630d Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 8 Oct 2026 22:41:25 +0530 Subject: [PATCH 5/7] feat(tenants): personalise seeded user credentials for a brand-new tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TenantInitialUserProvisioner: right after the very first Liquibase migration of a tenant that was never on Flyway, renames the three seeded users (ids 1-3, otherwise shipping as mifos/system/interopUser with the public default password) to _admin/_system/ _interop, with a password from fineract.tenant.initial-user-password or a built-in default. An existing (Flyway-era) tenant already has its real users and is left untouched. Wired through TenantDatabaseUpgradeService and its two test configurations. Also fixes the two pooled interest-posting services' system-user lookup, which read username = 'system' — now wrong since that username is personalised per tenant — to find the seeded system user by its fixed id (2) instead. Co-Authored-By: Claude Sonnet 5 --- .../TenantDatabaseUpgradeService.java | 6 ++ .../TenantInitialUserProvisioner.java | 88 +++++++++++++++++++ .../SavingsPooledInterestPostingService.java | 4 +- .../FixedDepositInterestPostingService.java | 4 +- .../apache/fineract/TestConfiguration.java | 4 +- .../core/LiquibaseStepDefinitions.java | 4 +- 6 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java index 9f4e81033..02b3c950d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java @@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean { private final ExtendedSpringLiquibaseFactory liquibaseFactory; private final TenantDataSourceFactory tenantDataSourceFactory; private final Environment environment; + private final TenantInitialUserProvisioner initialUserProvisioner; // DO NOT REMOVE! Required for liquibase custom task initialization private final List customTaskChangesForDependencyInjection; @@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean { // configuration // settings, and we should not use them to control the script order if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) { + // a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones + final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource); ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier()); applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(), (ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds)); + if (brandNewTenant) { + initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier()); + } } SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT, tenant.getTenantIdentifier()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java new file mode 100644 index 000000000..eb0b17c3d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java @@ -0,0 +1,88 @@ +/** + * 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.infrastructure.core.service.migration; + +import java.util.Locale; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3, + * which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the + * public default password). + * + * Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never + * touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and + * break validation on every tenant that already ran it. + * + * Usernames become {@code _admin}, {@code _system} and + * {@code _interop}. All three share one password: the value of + * {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set, + * otherwise {@link #DEFAULT_PASSWORD}. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class TenantInitialUserProvisioner { + + static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password"; + + private static final long ADMIN_ID = 1L; + private static final long SYSTEM_ID = 2L; + private static final long INTEROP_ID = 3L; + // Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars). + static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure"; + + private final Environment environment; + + public void provision(final DataSource dataSource, final String tenantIdentifier) { + final JdbcTemplate jdbc = new JdbcTemplate(dataSource); + final String prefix = usernamePrefix(tenantIdentifier); + + final String configured = environment.getProperty(PASSWORD_PROPERTY); + final boolean overridden = configured != null && !configured.isBlank(); + final String password = overridden ? configured : DEFAULT_PASSWORD; + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + final String encoded = encoder.encode(password); + + update(jdbc, ADMIN_ID, prefix + "_admin", encoded); + update(jdbc, SYSTEM_ID, prefix + "_system", encoded); + update(jdbc, INTEROP_ID, prefix + "_interop", encoded); + + log.warn("Tenant '{}' initial users created: {}_admin, {}_system, {}_interop (password: {}). Change it after first login.", + tenantIdentifier, prefix, prefix, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default"); + } + + private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) { + jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username, + encodedPassword, id); + } + + static String usernamePrefix(final String tenantIdentifier) { + final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", ""); + return cleaned.isEmpty() ? "tenant" : cleaned; + } + +} 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 2082c2c46..8cac4b0b7 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 @@ -424,8 +424,8 @@ public class SavingsPooledInterestPostingService { * columns are NOT NULL, so entries are stamped to the system account the scheduler uses. */ private Long systemUserId() { - final List ids = jdbcTemplate.queryForList( - "SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class); + // id 2 is the seeded system user; its username is per-tenant now, so it is found by id + final List ids = jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class); return ids.isEmpty() ? 1L : ids.get(0); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java index aef8786e5..33ba4630e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdinterest/FixedDepositInterestPostingService.java @@ -469,8 +469,8 @@ public class FixedDepositInterestPostingService { * scheduler's own postings always were. */ private Long systemUserId() { - final List ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", - Long.class); + // id 2 is the seeded system user; its username is per-tenant now, so it is found by id + final List ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class); return ids.isEmpty() ? 1L : ids.get(0); } diff --git a/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java b/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java index 4f38aadbf..11ae68a4a 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java +++ b/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java @@ -159,7 +159,9 @@ public class TestConfiguration { FineractProperties fineractProperties, Environment environment, List customTaskChangesForDependencyInjection) { return new TenantDatabaseUpgradeService(tenantDetailsService, tenantDataSource, fineractProperties, tenantDatabaseStateVerifier, - liquibaseFactory, tenantDataSourceFactory, environment, customTaskChangesForDependencyInjection); + liquibaseFactory, tenantDataSourceFactory, environment, + new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment), + customTaskChangesForDependencyInjection); } /** diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java index a22499905..621bbe96e 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java @@ -189,6 +189,8 @@ public class LiquibaseStepDefinitions implements En { .willReturn(tenantLiquibase); tenantDatabaseUpgradeService = new TenantDatabaseUpgradeService(tenantDetailsService, tenantStoreDataSource, fineractProperties, - databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment, Arrays.asList(tenantPasswordEncryptor)); + databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment, + new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment), + Arrays.asList(tenantPasswordEncryptor)); } } From 1fd94615a9ae047a85e09ad1c36bd140743d0825 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 9 Oct 2026 12:50:11 +0530 Subject: [PATCH 6/7] revert(fixed-deposits): remove FD maturity approval report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed deposit's maturity is settled directly by the nightly updateMaturityDetails job again, unconditionally, for every deposit type and every closure instruction — the approval gate this introduced turned out to risk more than it protected and is being removed before it ever shipped enabled by default. Removes the fdmaturity package entire (report service, orchestrator, API resource, noon-generation job), its migration and global configuration, the day-close integration in BranchDayRegisterApiResource, and FixedDepositAccount#previewMaturityInterestPeriods, which existed only for this report's preview and has no other caller. The periodic FD interest posting approval report (0593) is untouched — a matured deposit's final interest, paid directly by the nightly job, already surfaces there under "Posted outside approval", same as it did before this report existed. 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, 1 insertion(+), 1575 deletions(-) delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityOrchestrator.java delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityReportService.java delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/api/FixedDepositMaturityReportApiResource.java delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportConfig.java delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportTasklet.java delete 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 d8ae49255..41cd2bf0b 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,7 +33,6 @@ 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 4141516ed..fb0c03c76 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,13 +75,6 @@ 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 7828413bb..b442ca1af 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,7 +45,6 @@ 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 5aa32f27e..2b5f99d3b 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,11 +201,6 @@ 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 c2e3f4018..79720f616 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,8 +50,6 @@ 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; @@ -97,8 +95,6 @@ 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 ────────────────────────────── @@ -249,11 +245,6 @@ 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); } @@ -644,17 +635,6 @@ 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. @@ -807,25 +787,6 @@ 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 58f928283..5226ede94 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,38 +719,6 @@ 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 deleted file mode 100644 index bf4ea896c..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityOrchestrator.java +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 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 deleted file mode 100644 index e1aa10ca6..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/FixedDepositMaturityReportService.java +++ /dev/null @@ -1,691 +0,0 @@ -/** - * 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 deleted file mode 100644 index bc1d2b635..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/api/FixedDepositMaturityReportApiResource.java +++ /dev/null @@ -1,198 +0,0 @@ -/** - * 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 deleted file mode 100644 index d56cb00e9..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportConfig.java +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 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 deleted file mode 100644 index f4b498a4e..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/fdmaturity/jobs/FixedDepositMaturityReportTasklet.java +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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 83cb843de..0a02bfc77 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,7 +18,6 @@ */ 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; @@ -31,7 +30,6 @@ 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 @@ -45,10 +43,6 @@ public class UpdateDepositsAccountMaturityDetailsConfig { private DepositAccountReadPlatformService depositAccountReadPlatformService; @Autowired private DepositAccountWritePlatformService depositAccountWritePlatformService; - @Autowired - private ConfigurationDomainService configurationDomainService; - @Autowired - private JdbcTemplate jdbcTemplate; @Bean protected Step updateDepositsAccountMaturityDetailsStep() { @@ -64,7 +58,6 @@ public class UpdateDepositsAccountMaturityDetailsConfig { @Bean public UpdateDepositsAccountMaturityDetailsTasklet updateDepositsAccountMaturityDetailsTasklet() { - return new UpdateDepositsAccountMaturityDetailsTasklet(depositAccountReadPlatformService, depositAccountWritePlatformService, - configurationDomainService, jdbcTemplate); + return new UpdateDepositsAccountMaturityDetailsTasklet(depositAccountReadPlatformService, depositAccountWritePlatformService); } } 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 7e8a6760c..5f956586a 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,7 +22,6 @@ 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; @@ -34,7 +33,6 @@ 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 @@ -42,27 +40,15 @@ 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(); @@ -79,12 +65,4 @@ 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 b8115ea1b..470c64207 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,5 +166,4 @@ - 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 deleted file mode 100644 index d7e28ad9b..000000000 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_fd_maturity_interest_posting_approval.xml +++ /dev/null @@ -1,298 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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' - - - - - - - - - - - - From aceea89c5c4f0caedda1f510e0e0ba6d7dc45124 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 12 Oct 2026 11:55:18 +0530 Subject: [PATCH 7/7] fix(tenants): keep the 'system' username when provisioning a new tenant Problem Commit 3d3ce92e9 personalised the three users seeded into a brand-new tenant database and renamed user id 2 from 'system' to '_system' (for example 'default_system'). Fineract looks this user up by the fixed name AppUserConstants.SYSTEM_USER_NAME ('system') through AppUserRepositoryWrapper.fetchSystemUser(), so on every fresh tenant: - every scheduler job failed once a minute with "UserNotFoundException: User with name 'system' does not exist" (seen with the Send Asynchronous Events job); - COB initialisation, savings escheat, current account closure and the stuck-job listener would fail in the same way. Existing tenants were not affected because the provisioner only runs right after the very first migration of a new tenant. Fix - TenantInitialUserProvisioner now sets user id 2's username to AppUserConstants.SYSTEM_USER_NAME and only replaces its password. - Admin and interop users are still personalised as '_admin' and '_interop'. - Javadoc and the startup log line now say the system user keeps its name and why. Resulting users on a new tenant - _admin (password: initial-user-password or built-in default) - system (same password, name unchanged) - _interop (same password) Tenants already created with '_system' need a one-off fix: UPDATE m_appuser SET username = 'system' WHERE id = 2; Co-Authored-By: Claude Opus 5.5 --- .../migration/TenantInitialUserProvisioner.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java index eb0b17c3d..80ca8c636 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantInitialUserProvisioner.java @@ -22,6 +22,7 @@ import java.util.Locale; import javax.sql.DataSource; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.useradministration.service.AppUserConstants; import org.springframework.core.env.Environment; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.factory.PasswordEncoderFactories; @@ -37,8 +38,9 @@ import org.springframework.stereotype.Component; * touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and * break validation on every tenant that already ran it. * - * Usernames become {@code _admin}, {@code _system} and - * {@code _interop}. All three share one password: the value of + * Usernames become {@code _admin} and {@code _interop}. The system user keeps the + * {@link AppUserConstants#SYSTEM_USER_NAME} username because scheduler jobs, COB and other background processes look it + * up by that name; only its password is changed. All three share one password: the value of * {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set, * otherwise {@link #DEFAULT_PASSWORD}. */ @@ -68,11 +70,11 @@ public class TenantInitialUserProvisioner { final String encoded = encoder.encode(password); update(jdbc, ADMIN_ID, prefix + "_admin", encoded); - update(jdbc, SYSTEM_ID, prefix + "_system", encoded); + update(jdbc, SYSTEM_ID, AppUserConstants.SYSTEM_USER_NAME, encoded); update(jdbc, INTEROP_ID, prefix + "_interop", encoded); - log.warn("Tenant '{}' initial users created: {}_admin, {}_system, {}_interop (password: {}). Change it after first login.", - tenantIdentifier, prefix, prefix, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default"); + log.warn("Tenant '{}' initial users created: {}_admin, {}, {}_interop (password: {}). Change it after first login.", + tenantIdentifier, prefix, AppUserConstants.SYSTEM_USER_NAME, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default"); } private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) {