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'