Revert "fix(savings,closure): post interest for real when a closure is checked"

This reverts commit c86e0d1cc2.
This commit is contained in:
2026-09-02 18:31:45 +05:30
parent c86e0d1cc2
commit 45c2826a42
@@ -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:
* <ul>
* <li>The domain {@code postInterest} touches nothing outside the entity graph: no repository
* save, no journal entry, no voucher, no event. All of that lives in the service layer, which is
* deliberately bypassed here.</li>
* <li>REQUIRES_NEW, so marking this rollback-only cannot poison a caller's transaction, and the
* dirtied entities live in a separate persistence context that is discarded.</li>
* <li>Config is read before the account is assembled: a cache-miss JPA query afterwards could
* auto-flush the in-memory interest transactions, and while the rows would roll back, the
* identity-sequence values they consumed would not.</li>
* </ul>
*/
@POST
@GET
@Path("/preview/{savingsAccountId}")
@Produces(MediaType.APPLICATION_JSON)
public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) {
final List<Map<String, Object>> 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<String, Object> 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);
}