feat(fixed-deposits): maturity approval report, scoped to deposits with no closure instruction

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:06:40 +05:30
co-authored by Claude Sonnet 5
parent 6e7eb94776
commit 1a914a3de0
15 changed files with 1575 additions and 1 deletions
@@ -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";
@@ -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();
@@ -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"), //
@@ -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(
@@ -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<String, Object> 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<String, Object> 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<String> 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<Long> 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<String> 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
@@ -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<PostingPeriod> 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<PostingPeriod> postingPeriods = calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill,
postReversals);
final List<PostingPeriod> 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.
* <p>
@@ -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.
*
* <p>
* 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<String> postReports(final List<Long> reportIds) {
final TransactionTemplate newTransaction = new TransactionTemplate(this.transactionManager);
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
final List<String> 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<String, Object> 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<Map<String, Object>> 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<String> lineFailures = new ArrayList<>();
for (final Map<String, Object> 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;
}
}
@@ -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.
*
* <p>
* 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.
*
* <p>
* 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<DueLine> 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<Object> 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<Map<String, Object>> rows = this.jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
final Map<String, Object> 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<DueLine> due = new ArrayList<>();
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
for (final Map<String, Object> 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<PostingPeriod> 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<Long> generateDue(final LocalDate postingDate) {
final List<DueLine> due = computeDue(postingDate, null);
final List<Long> generated = new ArrayList<>();
final List<String> failures = new ArrayList<>();
for (final Map.Entry<Long, List<DueLine>> 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<DueLine> 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<Map<String, Object>> 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<String, Object> 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<DueLine> lines) {
final List<Map<String, Object>> 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<String, Object> 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<Long, List<DueLine>> groupByOffice(final List<DueLine> lines) {
final Map<Long, List<DueLine>> 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<String> 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<String, Object> dayCloseMaturityView(final Long officeId, final LocalDate businessDate) {
final Map<String, Object> view = new LinkedHashMap<>();
if (!this.configurationDomainService.isFixedDepositMaturityInterestPostingApprovalEnabled()) {
view.put("pending", List.of());
view.put("due", List.of());
return view;
}
final List<DueLine> 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<String, Object> 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<String> blockingFrom(final Long officeId, final LocalDate businessDate, final List<DueLine> due) {
final Set<String> blocking = new LinkedHashSet<>();
if (!due.isEmpty() && activeApprovedOrPostedReportId(officeId, businessDate) == null) {
blocking.add("Fixed deposit maturities (" + businessDate + ")");
}
for (final Map<String, Object> 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<String> 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<Long> 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<Long> 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<Map<String, Object>> 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<Object> 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<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> row : this.jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> 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<String, Object> detail(final Long reportId) {
final List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
final List<Map<String, Object>> 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<DueLine> due = computeDue(postingDate, others ? null : userOffice);
for (final Map.Entry<Long, List<DueLine>> 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<String, Object> 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.
*
* <p>
* 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<Map<String, Object>> 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<Object> 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<String> 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<Long> 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<Long> 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;
}
}
@@ -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<String, Object> 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<String, Object> view = this.reportService.dayCloseMaturityView(officeId, businessDate);
@SuppressWarnings("unchecked")
final List<String> blocking = (List<String>) view.get("pending");
final Map<String, Object> 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<String, Object> report = plain(this.reportService.detail(reportId));
@SuppressWarnings("unchecked")
final List<Map<String, Object>> lines = (List<Map<String, Object>>) 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<String, Object> plain(final Map<String, Object> row) {
final Map<String, Object> 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;
}
}
@@ -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();
}
}
@@ -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.
*
* <p>
* 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;
}
}
@@ -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);
}
}
@@ -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<DepositAccountData> 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<ApiParameterError> 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<Integer> 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;
}
}
@@ -166,4 +166,5 @@
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0594_fd_maturity_interest_posting_approval.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,298 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
A fixed deposit maturing stops settling itself the moment it matures.
Until now, the nightly "Update Deposits Account Maturity Details" job did three things to a
matured deposit in one breath, the night its term ends: posted whatever interest the account
had not yet been paid, up to the maturity date; moved the account to MATURED; and, where the
customer had asked for Reinvest or Transfer to Savings on closure, ran that too, moving the
matured balance out the same night. Nobody looked at the figure before it moved.
This is the same gate 0593 put fixed deposit interest behind, extended to the maturity event
itself - but only for a deposit with nothing decided for it. One that already named Reinvest or
Transfer to Savings on closure still settles itself the moment it matures, exactly as it always
has: a decision the customer already made in writing needs no second approval, only the one
nobody has made yet does. For that undecided deposit, a report lists every such deposit of one
branch maturing on a date and what its last posting would come to; a second user approves it;
and only then, as the branch closes its day, does the real maturity job run for that account -
the existing DepositAccountWritePlatformService#updateMaturityDetails, untouched, doing exactly
what it always did, just no longer on a timer nobody signed off on. Until approved the account
stays ACTIVE: status, interest and whatever is later chosen from the FD Dashboard change
together, in the one real run, not in stages.
A branch with such a report pending cannot close, on the same grounds unapproved interest already
stops it: money is about to move on this account's behalf and that needs the same two eyes.
-->
<changeSet author="fineract" id="0594-1-fd-maturity-report">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_fd_maturity_report"/></not>
</preConditions>
<createTable tableName="m_fd_maturity_report">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="office_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_fdmr_office" referencedTableName="m_office"
referencedColumnNames="id"/>
</column>
<!-- The maturity date every line on this report shares; one report per branch per date,
across every fixed deposit product - unlike the interest report, a maturity is not
priced per product, so there is no reason to split one. -->
<column name="posting_date" type="DATE">
<constraints nullable="false"/>
</column>
<!-- GENERATED, APPROVED, POSTED, or SUPERSEDED by a regeneration. -->
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="total_interest" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="account_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- AUTO from the scheduled run, MANUAL from a regeneration. -->
<column name="generation_mode" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="generated_by" type="BIGINT"/>
<column name="generated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="approved_by" type="BIGINT"/>
<column name="approved_at" type="TIMESTAMP"/>
<column name="approved_on_behalf" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="posted_at" type="TIMESTAMP"/>
<column name="posting_run_id" type="BIGINT"/>
<column name="failure_reason" type="VARCHAR(1000)"/>
</createTable>
<createIndex tableName="m_fd_maturity_report" indexName="idx_fdmr_office_date">
<column name="office_id"/>
<column name="posting_date"/>
</createIndex>
<createIndex tableName="m_fd_maturity_report" indexName="idx_fdmr_status_date">
<column name="status"/>
<column name="posting_date"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0594-2-fd-maturity-report-line">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_fd_maturity_report_line"/></not>
</preConditions>
<createTable tableName="m_fd_maturity_report_line">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="report_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_fdmrl_report"
referencedTableName="m_fd_maturity_report" referencedColumnNames="id"/>
</column>
<column name="fd_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_fdmrl_account" referencedTableName="m_savings_account"
referencedColumnNames="id"/>
</column>
<column name="account_no" type="VARCHAR(50)"/>
<column name="client_name" type="VARCHAR(300)"/>
<column name="office_id" type="BIGINT"/>
<column name="product_id" type="BIGINT"/>
<column name="product_name" type="VARCHAR(200)"/>
<column name="fd_type" type="VARCHAR(20)" defaultValue="SINGLE"/>
<column name="maturity_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="deposit_amount" type="DECIMAL(19,6)"/>
<column name="interest_rate" type="DECIMAL(19,6)"/>
<!-- What postMaturityInterest would still credit, as at generation - possibly zero, where
the deposit's last period was already posted and maturity itself pays nothing further. -->
<column name="estimated_interest" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- NONE, REINVEST_PRINCIPAL, REINVEST_PRINCIPAL_AND_INTEREST, or TRANSFER_TO_SAVINGS - the
account's own closure instruction, shown so the checker knows what approving this line
sets in motion beyond the interest. NONE sits matured until renewed from the FD
Dashboard, as it always has; the others close or renew the same night this posts. -->
<column name="closure_instruction" type="VARCHAR(40)" defaultValue="NONE">
<constraints nullable="false"/>
</column>
<!-- Where REINVEST or TRANSFER names a target, for display only - the real target is read
fresh from the account when this line is posted, not from what is cached here. -->
<column name="closure_account_no" type="VARCHAR(50)"/>
<!-- Set once DepositAccountWritePlatformService#updateMaturityDetails has actually run for
this account - the maturity job itself decides the figures; this only marks that it ran. -->
<column name="posted_at" type="TIMESTAMP"/>
<!-- One deposit's maturity failing (a bad GL mapping on its closure target, say) must not
hold up every other deposit maturing in the same branch on the same day, so each line
posts in a transaction of its own and keeps its own reason where that one failed. -->
<column name="failure_reason" type="VARCHAR(1000)"/>
</createTable>
<createIndex tableName="m_fd_maturity_report_line" indexName="idx_fdmrl_report">
<column name="report_id"/>
</createIndex>
<createIndex tableName="m_fd_maturity_report_line" indexName="idx_fdmrl_account">
<column name="fd_account_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0594-3-fd-maturity-run">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_fd_maturity_run"/></not>
</preConditions>
<createTable tableName="m_fd_maturity_run">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="report_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_fdmru_report"/>
</column>
<column name="office_id" type="BIGINT"/>
<column name="posting_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="account_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- How many of account_count actually matured in this attempt - a report can be retried,
and a retry's run only carries the deposits still unposted, not the whole report again. -->
<column name="posted_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- PENDING, COMPLETED or FAILED. -->
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="failure_reason" type="VARCHAR(1000)"/>
<column name="started_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
<column name="completed_at" type="TIMESTAMP"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="0594-4-fd-maturity-report-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Generate Fixed Deposit Maturity Report'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="Generate Fixed Deposit Maturity Report"/>
<column name="display_name" value="Generate Fixed Deposit Maturity Report"/>
<column name="short_name" value="FDMATRPT"/>
<column name="cron_expression" value="0 0 12 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Generate Fixed Deposit Maturity ReportJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0594-5-fd-maturity-report-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_FDMATURITYREPORT',
'REGENERATE_FDMATURITYREPORT', 'APPROVE_FDMATURITYREPORT')
</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_FDMATURITYREPORT"/>
<column name="entity_name" value="FDMATURITYREPORT"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="REGENERATE_FDMATURITYREPORT"/>
<column name="entity_name" value="FDMATURITYREPORT"/>
<column name="action_name" value="REGENERATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<!-- can_maker_checker stays false on APPROVE for the same reason as the interest report: the
report IS the maker-checker record. -->
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="APPROVE_FDMATURITYREPORT"/>
<column name="entity_name" value="FDMATURITYREPORT"/>
<column name="action_name" value="APPROVE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0594-6-grant-fd-maturity-report-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
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')
</sqlCheck>
</preConditions>
<sql>
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
)
</sql>
</changeSet>
<!--
The switch back. With this on, a matured fixed deposit waits for this report's approval before
its interest posts and its closure instructions run; with it off, the nightly job settles it
directly the moment it matures, as it always did. Off by default, deliberately - turning this
on is a bigger behavioural change than the interest report alone (it also delays reinvestment
and transfer-on-closure), and a bank should choose it, not inherit it from an upgrade.
-->
<changeSet author="fineract" id="0594-7-fd-maturity-interest-posting-approval-configuration">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM c_configuration WHERE name = 'fd-maturity-interest-posting-approval'
</sqlCheck>
</preConditions>
<insert tableName="c_configuration">
<column name="name" value="fd-maturity-interest-posting-approval"/>
<column name="value" valueNumeric="0"/>
<column name="enabled" valueBoolean="false"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description"
value="A fixed deposit maturing is 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. Turn this off to let the nightly job settle maturities directly, as it did before."/>
</insert>
</changeSet>
</databaseChangeLog>