Revert "fix(savings,closure): post interest for real when a closure is checked"
This reverts commit c86e0d1cc2.
This commit is contained in:
+74
-39
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user