diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeBookPendingRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeBookPendingRequest.java index 5bc06507c..985128d34 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeBookPendingRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/domain/ChequeBookPendingRequest.java @@ -85,6 +85,14 @@ public class ChequeBookPendingRequest extends AbstractPersistableCustom { @Column(name = "activity_log") private String activityLog; + /** + * The hold-amount transaction (savings/current) placed on the account when this request was submitted, so + * the cheque-book issuance charge is reserved for the pending duration — released on approval (then + * charged) or on rejection (returned). Null for request types other than REQUEST_BOOK. + */ + @Column(name = "hold_transaction_id") + private Long holdTransactionId; + public static ChequeBookPendingRequest submit(final ChequeBookPendingRequestType requestType, final String payloadJson, final AppUser maker, final String activityLog) { final ChequeBookPendingRequest request = new ChequeBookPendingRequest(); @@ -99,6 +107,10 @@ public class ChequeBookPendingRequest extends AbstractPersistableCustom { return request; } + public void recordHold(final Long holdTransactionId) { + this.holdTransactionId = holdTransactionId; + } + public boolean isPending() { return ChequeBookPendingRequestStatus.PENDING.name().equals(this.status); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java new file mode 100644 index 000000000..1ce792e7a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -0,0 +1,347 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.chequebook.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.commands.service.SynchronousCommandProcessingService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepositoryWrapper; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionSummaryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsHelper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Per-leaf cheque-book issuance charge: rate lookup, balance-sufficiency validation, and the actual + * deduction + GL posting once a request is approved. Kept out of {@link ChequeBookWritePlatformServiceImpl} + * as its own bean, mirroring how {@code ChequeSeriesHardDeleteAttempter} was split out for the same reason + * — this is a self-contained unit of financial logic that's easier to review and reuse in isolation. + * + *

+ * Reuses the existing "CHEQUE_BOOK" facility charge (also used by {@code FacilityChargeAutoApplyService} + * for the flat one-time activation-time fee) — its {@code amount} is interpreted here as a per-leaf rate. + * Both uses share the same {@code m_charge} row by design (confirmed with the bank): changing this + * charge's amount affects the activation fee and the per-leaf issuance rate together. + *

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ChequeBookIssuanceChargeService { + + private static final String FACILITY_TYPE = "CHEQUE_BOOK"; + + private final FacilityChargeMappingRepository facilityChargeMappingRepository; + private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; + private final CurrentAccountRepositoryWrapper currentAccountRepositoryWrapper; + private final CurrentAccountRepository currentAccountRepository; + private final SavingsAccountChargeRepository savingsAccountChargeRepository; + private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; + private final CurrentAccountTransactionRepository currentAccountTransactionRepository; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final FineractRequestContextHolder fineractRequestContextHolder; + private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper; + private final SavingsHelper savingsHelper; + + /** The configured charge, if the admin has set one up — absent means "no charge configured yet." */ + private FacilityChargeMapping resolveMapping() { + return facilityChargeMappingRepository.findByFacilityType(FACILITY_TYPE) + .filter(m -> Boolean.TRUE.equals(m.getIsActive())) + .filter(m -> m.getCharge() != null && m.getCharge().isActive()) + .orElse(null); + } + + /** + * Per-leaf rate to charge for this book. A missing/inactive admin configuration degrades to "no + * charge" (zero) rather than blocking the whole cheque-book workflow. + */ + public BigDecimal ratePerLeaf() { + final FacilityChargeMapping mapping = resolveMapping(); + if (mapping == null) { + log.warn("Cheque book issuance charge ('{}') is not configured or inactive - no charge will be applied.", FACILITY_TYPE); + return BigDecimal.ZERO; + } + return mapping.getCharge().getAmount(); + } + + /** + * Throws if this account cannot absorb {@code chargeAmount} without breaching its minimum required + * balance. Called both at request-submit time (fail fast) and again at approval time (authoritative + * re-check, in case the rate or the account's balance changed in between). + */ + public void assertBalanceCovers(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount) { + if (chargeAmount == null || chargeAmount.signum() <= 0) { + return; + } + if (accountType == ChequeAccountType.SAVINGS) { + assertSavingsBalanceCovers(accountId, chargeAmount); + } else { + assertCurrentBalanceCovers(accountId, chargeAmount); + } + } + + private void assertSavingsBalanceCovers(final Long accountId, final BigDecimal chargeAmount) { + final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId); + final BigDecimal withdrawable = account.getWithdrawableBalance(); + if (chargeAmount.compareTo(withdrawable) > 0) { + throw new GeneralPlatformDomainRuleException("error.msg.chequebook.charge.insufficient.balance", + "Savings account " + accountId + " cannot absorb the cheque-book issuance charge of " + chargeAmount + + " without breaching its minimum required balance. Available: " + withdrawable + ".", + accountId, chargeAmount, withdrawable); + } + } + + private void assertCurrentBalanceCovers(final Long accountId, final BigDecimal chargeAmount) { + final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId); + final BigDecimal currentBalance = safeBalance(account.getAccountBalanceDerived()); + final BigDecimal onHold = safeBalance(account.getOnHoldFundsDerived()); + final BigDecimal available = currentBalance.subtract(onHold); + + if (!account.isAllowOverdraft() && chargeAmount.compareTo(available) > 0) { + throw new GeneralPlatformDomainRuleException("error.msg.chequebook.charge.insufficient.balance", + "Current account " + accountId + " has insufficient available balance for the cheque-book issuance charge of " + + chargeAmount + ". Available: " + available + ".", + accountId, chargeAmount, available); + } + + final BigDecimal newBalance = currentBalance.subtract(chargeAmount); + if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null + && account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0 + && newBalance.compareTo(account.getMinRequiredBalance()) < 0) { + throw new GeneralPlatformDomainRuleException("error.msg.chequebook.charge.min.required.balance.breached", + "The cheque-book issuance charge of " + chargeAmount + " would breach the minimum required balance of " + + account.getMinRequiredBalance() + " on current account " + accountId + ". Balance after charge would be " + + newBalance + ".", + accountId, chargeAmount, account.getMinRequiredBalance()); + } + } + + private BigDecimal safeBalance(final BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + /** + * Reserves {@code chargeAmount} on the account via Fineract's native hold-amount command — called right + * after {@link #assertBalanceCovers} passes at request-submit time, so the money is set aside for the + * pending duration rather than being spendable elsewhere while the request awaits a checker. Returns the + * hold transaction's id (to be stored on the pending request row) so it can be released later — at + * approval (then charged) or at rejection (returned to the customer). Returns {@code null} if there's no + * charge to hold for (nothing to reserve). + */ + public Long placeHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount, + final LocalDate today) { + if (chargeAmount == null || chargeAmount.signum() <= 0) { + return null; + } + final JsonObject payload = new JsonObject(); + payload.addProperty("transactionAmount", chargeAmount); + payload.addProperty("transactionDate", today.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("reasonForBlock", "Reserved for cheque book issuance charge"); + payload.addProperty("lienAllowed", false); + + final CommandWrapper cmd = accountType == ChequeAccountType.SAVINGS + ? new CommandWrapperBuilder().holdAmount(accountId).withJson(payload.toString()).build() + : new CommandWrapperBuilder().holdAmountCurrentAccount(accountId).withJson(payload.toString()).build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result != null ? result.getResourceId() : null; + } + + /** + * Releases a previously-placed hold. Safe to call with a {@code null} id (nothing was held, e.g. the + * charge rate was zero at submit time) — becomes a no-op. + */ + public void releaseHold(final ChequeAccountType accountType, final Long accountId, final Long holdTransactionId) { + if (holdTransactionId == null) { + return; + } + final CommandWrapper cmd = accountType == ChequeAccountType.SAVINGS + ? new CommandWrapperBuilder().releaseAmount(accountId, holdTransactionId).build() + : new CommandWrapperBuilder().releaseAmountCurrentAccount(accountId, holdTransactionId).build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + this.commandsSourceWritePlatformService.logCommandSource(cmd); + } + + /** + * Deducts the charge and posts the corresponding GL entry. Only called from within an already-open + * transaction (the checker's approval) — the charge, GL entry, and book issuance commit or roll back + * together as one atomic unit, so no separate transaction boundary is introduced here. + */ + public void deduct(final ChequeAccountType accountType, final Long accountId, final Office office, final BigDecimal chargeAmount, + final Long chequeBookId) { + if (chargeAmount == null || chargeAmount.signum() <= 0) { + return; + } + final FacilityChargeMapping mapping = resolveMapping(); + if (mapping == null || mapping.getCharge() == null) { + log.warn("Cheque book {} approved but issuance charge is no longer configured - skipping charge deduction.", chequeBookId); + return; + } + final Charge charge = mapping.getCharge(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + + final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId) + : getCurrentControlGlId(accountId); + final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId() : null; + if (ownAccountGlId == null || incomeGlId == null) { + log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); " + + "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId); + return; + } + + if (accountType == ChequeAccountType.SAVINGS) { + deductFromSavings(accountId, charge, chargeAmount, today); + } else { + deductFromCurrent(accountId, office, chargeAmount, today); + } + + postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId, + accountId); + log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, + chequeBookId); + } + + private void deductFromSavings(final Long accountId, final Charge charge, final BigDecimal chargeAmount, final LocalDate today) { + final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId); + // a plain repository fetch leaves these transient collaborators unset — recalculateBalancesAfterFacilityCharges() + // NPEs without them (they're normally wired in by the account-assembly step of the full savings write flow) + account.setHelpers(this.savingsAccountTransactionSummaryWrapper, this.savingsHelper); + + final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, chargeAmount, null, null, + null, true, null, null); + accountCharge.update(account); + account.charges().add(accountCharge); + this.savingsAccountChargeRepository.saveAndFlush(accountCharge); + + final Money amountMoney = Money.of(account.getCurrency(), chargeAmount); + final SavingsAccountTransaction txn = account.payCharge(accountCharge, amountMoney, today, false, null); + this.savingsAccountTransactionRepository.saveAndFlush(txn); + account.recalculateBalancesAfterFacilityCharges(today); + this.savingsAccountRepositoryWrapper.saveAndFlush(account); + } + + private void deductFromCurrent(final Long accountId, final Office office, final BigDecimal chargeAmount, final LocalDate today) { + final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId); + final BigDecimal newBalance = safeBalance(account.getAccountBalanceDerived()).subtract(chargeAmount); + + final CurrentAccountTransaction txn = CurrentAccountTransaction.chargeDeduction(account, office, chargeAmount, today); + this.currentAccountTransactionRepository.saveAndFlush(txn); + account.setAccountBalanceDerived(newBalance); + this.currentAccountRepository.saveAndFlush(account); + } + + /** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */ + private Long getSavingsControlGlId(final Long savingsAccountId) { + final List> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa " + + "JOIN acc_product_mapping apm ON apm.product_id = sa.product_id " + + "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", savingsAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); + } + + private Long getCurrentControlGlId(final Long currentAccountId) { + final List> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca " + + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " + + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); + } + + /** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */ + private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, + final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) { + final JsonArray debitArr = new JsonArray(); + final JsonObject debit = new JsonObject(); + debit.addProperty("glAccountId", debitGlId); + debit.addProperty("amount", amount); + debitArr.add(debit); + + final JsonArray creditArr = new JsonArray(); + final JsonObject credit = new JsonObject(); + credit.addProperty("glAccountId", creditGlId); + credit.addProperty("amount", amount); + creditArr.add(credit); + + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", officeId); + payload.addProperty("transactionDate", txnDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "INR"); + payload.addProperty("comments", "Cheque book issuance charge - book #" + chequeBookId); + payload.add("debits", debitArr); + payload.add("credits", creditArr); + + final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + if (result == null) { + log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId); + return; + } + // Without this, the debit row posts fine but the Journal Entries screen shows no "Account No" for it — + // link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do. + final String glTxnId = result.getTransactionId(); + if (glTxnId != null) { + linkDebitEntryToAccount(glTxnId, accountId); + } + } + + /** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + final List rowIds = this.jdbcTemplate.queryForList( + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class, + glTxnId); + if (!rowIds.isEmpty()) { + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, + rowIds.get(0)); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java index 7bbdca631..884b90081 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookWritePlatformServiceImpl.java @@ -22,6 +22,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import java.math.BigDecimal; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -91,6 +92,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo private final CurrentProductFacilityConfigRepository currentProductFacilityConfigRepository; private final ChequeBookPendingRequestRepositoryWrapper chequeBookPendingRequestRepositoryWrapper; private final ChequeSeriesHardDeleteAttempter chequeSeriesHardDeleteAttempter; + private final ChequeBookIssuanceChargeService chequeBookIssuanceChargeService; private final FromJsonHelper fromApiJsonHelper; private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -315,6 +317,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo .fromString(command.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue())); final Long accountId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue()); final Long seriesId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue()); + final Integer noOfBooks = command.integerValueSansLocaleOfParameterNamed(ChequeBookApiJsonInputParams.NO_OF_BOOKS.getValue()); // fail fast for the maker; re-checked authoritatively at approval time in case anything changed meanwhile assertEligible(accountType, accountId); final ChequeSeries series = this.chequeSeriesRepositoryWrapper.findOneWithNotFoundDetection(seriesId); @@ -322,7 +325,16 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo throw new GeneralPlatformDomainRuleException("error.msg.chequebook.series.account.type.mismatch", "Cheque series " + seriesId + " is not applicable to a " + accountType + " account.", seriesId); } - final Long pendingId = submitPendingRequest(ChequeBookPendingRequestType.REQUEST_BOOK, command); + // per-leaf issuance charge must not push the account below its minimum required balance — checked + // at request time itself, not only on approval + final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf() + .multiply(BigDecimal.valueOf((long) noOfBooks * series.getLeavesPerBook())); + this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount); + // reserve the charge amount on the account for the pending duration — released (then charged) on + // approval, or released (returned) on rejection; never left dangling + final Long holdTransactionId = this.chequeBookIssuanceChargeService.placeHold(accountType, accountId, chargeAmount, + DateUtils.getBusinessLocalDate()); + final Long pendingId = insertPendingRequest(ChequeBookPendingRequestType.REQUEST_BOOK, command.json(), holdTransactionId); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(pendingId).build(); } @@ -334,7 +346,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo * out the book, so there is no separate "Issue" click left for anyone to do afterward. The book is * created and immediately drawn down from available stock in the same step. */ - private Long finalizeRequestChequeBook(final JsonCommand command, final Long makerId) { + private Long finalizeRequestChequeBook(final JsonCommand command, final Long makerId, final Long holdTransactionId) { final ChequeAccountType accountType = ChequeAccountType .fromString(command.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue())); final Long accountId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue()); @@ -351,11 +363,20 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo "Cheque series " + seriesId + " is not applicable to a " + accountType + " account.", seriesId); } + // leaves actually handed to the customer = requested books x this series' fixed denomination + final int totalLeaves = noOfBooks * series.getLeavesPerBook(); + final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf().multiply(BigDecimal.valueOf(totalLeaves)); + // release the hold placed at submit time before re-checking — otherwise the held amount would count + // against the account twice (once as "on hold", once as the charge being re-validated) + this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, holdTransactionId); + // authoritative re-check: rate or balance may have moved since the maker submitted this request + this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount); + final ChequeBook book = ChequeBook.requestNew(accountType, accountId, office, series, noOfBooks, makerId); final ChequeBook saved = this.chequeBookRepositoryWrapper.saveAndFlush(book); - // leaves actually handed to the customer = requested books x this series' fixed denomination - final int totalLeaves = noOfBooks * series.getLeavesPerBook(); + this.chequeBookIssuanceChargeService.deduct(accountType, accountId, office, chargeAmount, saved.getId()); + final List candidateLots = this.chequeStockLotRepository .findBySeriesIdAndOfficeIdAndStatusOrderByIdAsc(seriesId, officeId, ChequeStockLotStatus.AVAILABLE.name()); ChequeStockLot chosenLot = null; @@ -585,9 +606,16 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo } private Long insertPendingRequest(final ChequeBookPendingRequestType type, final String payloadJson) { + return insertPendingRequest(type, payloadJson, null); + } + + private Long insertPendingRequest(final ChequeBookPendingRequestType type, final String payloadJson, final Long holdTransactionId) { final AppUser maker = this.context.authenticatedUser(); final String activityLog = appendActivityLog(null, "SUBMITTED", maker, null); final ChequeBookPendingRequest request = ChequeBookPendingRequest.submit(type, payloadJson, maker, activityLog); + if (holdTransactionId != null) { + request.recordHold(holdTransactionId); + } final ChequeBookPendingRequest saved = this.chequeBookPendingRequestRepositoryWrapper.saveAndFlush(request); log.info("Cheque book pending request {} ({}) submitted by {}", saved.getId(), type, maker.getUsername()); return saved.getId(); @@ -614,7 +642,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo final Long createdResourceId = switch (type) { case CREATE_SERIES -> finalizeCreateSeries(replay, request.getMakerId()); case RECEIVE_STOCK -> finalizeReceiveStock(replay, request.getMakerId()); - case REQUEST_BOOK -> finalizeRequestChequeBook(replay, request.getMakerId()); + case REQUEST_BOOK -> finalizeRequestChequeBook(replay, request.getMakerId(), request.getHoldTransactionId()); case DELETE_SERIES -> finalizeDeleteSeries(replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue())); }; @@ -648,6 +676,16 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo "A reason is required to reject a cheque book pending request.", pendingRequestId); } + // a rejected book request returns any reserved issuance charge to the customer — never left on hold + if (request.getHoldTransactionId() != null + && ChequeBookPendingRequestType.REQUEST_BOOK.name().equals(request.getRequestType())) { + final JsonCommand replay = reconstructCommand(request.getPayloadJson()); + final ChequeAccountType accountType = ChequeAccountType + .fromString(replay.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue())); + final Long accountId = replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue()); + this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, request.getHoldTransactionId()); + } + final String activityLog = appendActivityLog(request.getActivityLog(), "REJECTED", checker, reason); request.reject(checker, reason, activityLog); this.chequeBookPendingRequestRepositoryWrapper.saveAndFlush(request); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 83cb412f8..eb67b68f7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -409,4 +409,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0389_chequebook_pending_request_hold.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0389_chequebook_pending_request_hold.xml new file mode 100644 index 000000000..4ef42f26a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0389_chequebook_pending_request_hold.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + +