feat(chequebook): per-leaf issuance charge with balance validation and fund hold
Requesting a cheque book now computes a per-leaf charge (reusing the existing "Cheque Book Charge" facility mapping) and blocks the request at submit time if the account can't absorb it without breaching its minimum required balance. The charge amount is reserved via Fineract's native hold-amount mechanism when the request is submitted, released and actually deducted (with GL posting) on checker approval, or released back to the customer on rejection — so funds are never left dangling on hold regardless of outcome. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3bb7fc8050
commit
fde670e644
+12
@@ -85,6 +85,14 @@ public class ChequeBookPendingRequest extends AbstractPersistableCustom<Long> {
|
||||
@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<Long> {
|
||||
return request;
|
||||
}
|
||||
|
||||
public void recordHold(final Long holdTransactionId) {
|
||||
this.holdTransactionId = holdTransactionId;
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
return ChequeBookPendingRequestStatus.PENDING.name().equals(this.status);
|
||||
}
|
||||
|
||||
+347
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
@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<Map<String, Object>> 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<Map<String, Object>> 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<Long> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-5
@@ -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<ChequeStockLot> 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);
|
||||
|
||||
@@ -409,4 +409,5 @@
|
||||
<include file="parts/0386_enable_global_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0387_chequeleaf_serial_unique_per_lot.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0388_chequeleaf_returned_status.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0389_chequebook_pending_request_hold.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Tracks the hold-amount transaction (m_savings_account_transaction / m_current_account_transaction id)
|
||||
placed on the account when a REQUEST_BOOK pending request is submitted, so the reserved cheque-book
|
||||
issuance charge can be released cleanly at approval (then charged) or at rejection (returned to the
|
||||
customer) without ever leaving funds stuck on hold.
|
||||
-->
|
||||
|
||||
<changeSet id="0389-chequebook-pending-request-hold-column" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_chequebook_pending_request" columnName="hold_transaction_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_chequebook_pending_request">
|
||||
<column name="hold_transaction_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user