diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index c8f2e453d..4d3d6f201 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -37,6 +37,7 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; +import java.math.MathContext; import java.math.RoundingMode; import java.sql.Date; import java.sql.Timestamp; @@ -52,12 +53,13 @@ import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; 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.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; @@ -65,7 +67,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; /** * Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s @@ -94,9 +99,10 @@ public class ClerkClosureRequestApiResource { private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - // Used only by preview(), to post interest for real the same way close() eventually will. + // Used only by preview(), to compute the closing figure the way close() will actually compute it. private final SavingsAccountAssembler savingsAccountAssembler; - private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + private final PlatformTransactionManager transactionManager; private final VoucherService voucherService; private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -169,45 +175,47 @@ public class ClerkClosureRequestApiResource { return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } - // ── POST /v1/clerk/closure-requests/preview/{savingsAccountId} ───────────── + // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── /** - * Posts interest to today for real -- the same domain call close() makes - * ({@code postInterest(account, true, businessDate, false)}) -- and reports back the resulting - * balance, so the clerk sees the exact figure the account will close with, already credited. + * "What would this member actually receive" preview, so a clerk sees the real closing figure + * before submitting. * - * This is a genuine posting, not an estimate. It has to be: a dry run reproducing close()'s own - * MathContext(10) and postInterestOnDate was tried first, but calling - * {@link org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService - * #postInterest(SavingsAccount, boolean, LocalDate, boolean)} directly -- the exact method - * close() itself calls -- makes the account's own real balance the source of truth instead, and - * removes an entire class of "did the simulation really match reality" risk. + * This must reproduce what close() will pay out, to the paisa, and reading the stored + * {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last + * touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is + * close but not identical -- it uses {@code MathContext(15)} and passes + * {@code postInterestOnDate = null}, whereas close() calls + * {@code postInterest(account, true, closedDate, false)}, which uses {@code MathContext(10)} and + * feeds {@code closedDate} in as an extra posting-period boundary + * (SavingsAccount#calculateInterestUsing adds it to postedAsOnDates). Splitting the final period + * changes how many per-period roundings happen, so the two can disagree by paise. * - * Called directly on the write-platform service, not through the ordinary POSTINTEREST command - * pipeline: that permission ({@code POSTINTEREST_SAVINGSACCOUNT}) ships maker-checker-enabled in - * base Fineract, and a Clerk is not a checker for it. Routed through - * {@code commandsSourceWritePlatformService} it would silently roll back and land in Fineract's - * own generic Checker Inbox -- a second, unrelated approval queue on top of this one. This - * feature already has its own maker-checker (submit here, approve in Closure Requests), so the - * interest posting rides inside that instead: real the moment the clerk ticks the box, same as - * "Post Interest As On" always was, with the actual approval gate being who signs off on the - * closure itself. + * So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method + * close() ends up in with the same arguments, read the resulting balance -- which is exactly the + * figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right + * after posting interest) -- and then roll the whole thing back. * - * Posting now and closing later is safe to leave uncommitted-to: if the clerk unticks the box or - * the request is rejected, the interest already posted is simply real interest the member had - * genuinely earned -- crediting it is correct with or without the closure going through, and - * re-ticking later just finds the same day's posting already there and no-ops (see - * SavingsAccount#postInterest's own correction check). If manager approval lands on a later - * business date, close() posts the extra days on top; it never re-posts what is already there - * for a date that matches. + * Why this is safe to run per keystroke: + * */ - @POST + @GET @Path("/preview/{savingsAccountId}") @Produces(MediaType.APPLICATION_JSON) public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) { final List> rows = jdbcTemplate.queryForList(""" SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum, - COALESCE(c.display_name, g.display_name) AS client_name + COALESCE(c.display_name, g.display_name) AS client_name, + COALESCE(sa.account_balance_derived, 0) AS balance FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id LEFT JOIN m_group g ON g.id = sa.group_id @@ -219,23 +227,49 @@ public class ClerkClosureRequestApiResource { final Map account = rows.get(0); // Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount / // RecurringDepositAccount, whose interest and tax on closure run through - // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- calling the plain - // savings postInterest on one of those would post the wrong figure. + // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- the dry run below + // would quietly compute the wrong number for them. final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); if (depositType != 100) { throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ")."); } + final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString()); // The CBS business date, not wall-clock: this is the date the closure will actually post on - // (executeClose sends it as closedOnDate) and the date close() itself would post interest up + // (executeClose sends it as closedOnDate) and the date close() itself calculates interest up // to. Using LocalDate.now() here would silently disagree with approval around a day roll. final LocalDate closureDate = DateUtils.getBusinessLocalDate(); - final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); - final BigDecimal balance = zeroIfNull(acct.getSummary().getAccountBalance()); - // The exact call close() makes: postInterest(account, true, closedDate, false). - savingsAccountWritePlatformService.postInterest(acct, true, closureDate, false); - final BigDecimal total = zeroIfNull(acct.getSummary().getAccountBalance()); + // Read configuration first -- see the flush note in the javadoc. + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth(); + final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode()); + + final TransactionTemplate dryRun = new TransactionTemplate(transactionManager); + dryRun.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + BigDecimal[] figures = null; + try { + figures = dryRun.execute(status -> { + final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); + final BigDecimal before = zeroIfNull(acct.getSummary().getAccountBalance()); + // The exact call close() makes, via postInterest(account, true, closedDate, false). + acct.postInterest(mc, closureDate, false, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, closureDate, false, false); + final BigDecimal after = zeroIfNull(acct.getSummary().getAccountBalance()); + status.setRollbackOnly(); + return new BigDecimal[] { before, after }; + }); + } catch (final RuntimeException e) { + // Never cost the clerk the preview entirely -- fall through to the stored balance and + // flag that the interest figure could not be computed. + figures = null; + } + + final boolean computed = figures != null; + final BigDecimal balance = computed ? figures[0] : storedBalance; + final BigDecimal total = computed ? figures[1] : storedBalance; final BigDecimal interest = total.subtract(balance); final JsonObject res = new JsonObject(); @@ -249,6 +283,7 @@ public class ClerkClosureRequestApiResource { // What a cashier could physically count out: no paisa coins circulate, so the hand-over is // always whole rupees (the difference lands on the Round Off Account at approval). res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); + res.addProperty("interestUpToDate", computed); res.addProperty("asOfDate", closureDate.toString()); return gson.toJson(res); }