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 4d3d6f201..c8f2e453d 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,7 +37,6 @@ 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; @@ -53,13 +52,12 @@ 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; @@ -67,10 +65,7 @@ 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 @@ -99,10 +94,9 @@ public class ClerkClosureRequestApiResource { private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - // Used only by preview(), to compute the closing figure the way close() will actually compute it. + // Used only by preview(), to post interest for real the same way close() eventually will. private final SavingsAccountAssembler savingsAccountAssembler; - private final ConfigurationDomainService configurationDomainService; - private final PlatformTransactionManager transactionManager; + private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; private final VoucherService voucherService; private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -175,47 +169,45 @@ public class ClerkClosureRequestApiResource { return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } - // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── + // ── POST /v1/clerk/closure-requests/preview/{savingsAccountId} ───────────── /** - * "What would this member actually receive" preview, so a clerk sees the real closing figure - * before submitting. + * 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. * - * 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. + * 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. * - * 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. + * 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. * - * Why this is safe to run per keystroke: - * + * 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. */ - @GET + @POST @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(sa.account_balance_derived, 0) AS balance + COALESCE(c.display_name, g.display_name) AS client_name 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 @@ -227,49 +219,23 @@ 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 -- the dry run below - // would quietly compute the wrong number for them. + // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- calling the plain + // savings postInterest on one of those would post the wrong figure. 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 calculates interest up + // (executeClose sends it as closedOnDate) and the date close() itself would post interest up // to. Using LocalDate.now() here would silently disagree with approval around a day roll. final LocalDate closureDate = DateUtils.getBusinessLocalDate(); - // 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 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()); final BigDecimal interest = total.subtract(balance); final JsonObject res = new JsonObject(); @@ -283,7 +249,6 @@ 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); }