feat(chequebook): silent balance hold instead of activation-time charge
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

- Removed the "CHEQUE_BOOK" facility charge from account-activation
  auto-apply (both savings and current) — the per-leaf issuance charge is
  now the only cheque-book charge, applied at request/approval time only.
- Cheque book request now reserves the charge amount via a direct
  on-hold-funds field update instead of Fineract's native hold-amount
  command, so no "Amount on hold"/"Release Amount" transaction row is ever
  created — nothing shows in the passbook/transactions list, and the
  reservation is released the same way (silently) on approval (then
  charged) or rejection (returned), for both savings and current accounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-17 15:03:30 +05:30
co-authored by Claude Sonnet 5
parent 5ed95c27e8
commit 383dfdc6e9
5 changed files with 291 additions and 302 deletions
@@ -85,14 +85,6 @@ public class ChequeBookPendingRequest extends AbstractPersistableCustom<Long> {
@Column(name = "activity_log") @Column(name = "activity_log")
private String activityLog; 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, public static ChequeBookPendingRequest submit(final ChequeBookPendingRequestType requestType, final String payloadJson,
final AppUser maker, final String activityLog) { final AppUser maker, final String activityLog) {
final ChequeBookPendingRequest request = new ChequeBookPendingRequest(); final ChequeBookPendingRequest request = new ChequeBookPendingRequest();
@@ -107,10 +99,6 @@ public class ChequeBookPendingRequest extends AbstractPersistableCustom<Long> {
return request; return request;
} }
public void recordHold(final Long holdTransactionId) {
this.holdTransactionId = holdTransactionId;
}
public boolean isPending() { public boolean isPending() {
return ChequeBookPendingRequestStatus.PENDING.name().equals(this.status); return ChequeBookPendingRequestStatus.PENDING.name().equals(this.status);
} }
@@ -168,47 +168,45 @@ public class ChequeBookIssuanceChargeService {
} }
/** /**
* Reserves {@code chargeAmount} on the account via Fineract's native hold-amount command — called right * Reserves {@code chargeAmount} on the account by directly incrementing its on-hold-funds field — a
* after {@link #assertBalanceCovers} passes at request-submit time, so the money is set aside for the * plain entity mutation, not Fineract's native hold-amount command, so it creates no transaction row
* pending duration rather than being spendable elsewhere while the request awaits a checker. Returns the * and never appears in the passbook/transactions list. Called right after {@link #assertBalanceCovers}
* hold transaction's id (to be stored on the pending request row) so it can be released later — at * passes at request-submit time, so the money is set aside for the pending duration rather than being
* approval (then charged) or at rejection (returned to the customer). Returns {@code null} if there's no * spendable elsewhere while the request awaits a checker.
* charge to hold for (nothing to reserve).
*/ */
public Long placeHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount, public void placeHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount) {
final LocalDate today) {
if (chargeAmount == null || chargeAmount.signum() <= 0) { if (chargeAmount == null || chargeAmount.signum() <= 0) {
return null; return;
}
if (accountType == ChequeAccountType.SAVINGS) {
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
account.holdAmount(chargeAmount);
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
} else {
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
account.setOnHoldFundsDerived(safeBalance(account.getOnHoldFundsDerived()).add(chargeAmount));
this.currentAccountRepository.saveAndFlush(account);
} }
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 * Releases a previously-placed hold — reverses the same field increment, again with no transaction row.
* charge rate was zero at submit time) — becomes a no-op. * Safe to call with a zero/null amount (nothing was held) — becomes a no-op.
*/ */
public void releaseHold(final ChequeAccountType accountType, final Long accountId, final Long holdTransactionId) { public void releaseHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount) {
if (holdTransactionId == null) { if (chargeAmount == null || chargeAmount.signum() <= 0) {
return; return;
} }
final CommandWrapper cmd = accountType == ChequeAccountType.SAVINGS if (accountType == ChequeAccountType.SAVINGS) {
? new CommandWrapperBuilder().releaseAmount(accountId, holdTransactionId).build() final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
: new CommandWrapperBuilder().releaseAmountCurrentAccount(accountId, holdTransactionId).build(); account.releaseOnHoldAmount(chargeAmount);
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); this.savingsAccountRepositoryWrapper.saveAndFlush(account);
this.commandsSourceWritePlatformService.logCommandSource(cmd); } else {
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
final BigDecimal newHold = safeBalance(account.getOnHoldFundsDerived()).subtract(chargeAmount).max(BigDecimal.ZERO);
account.setOnHoldFundsDerived(newHold);
this.currentAccountRepository.saveAndFlush(account);
}
} }
/** /**
@@ -330,11 +330,11 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf() final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf()
.multiply(BigDecimal.valueOf((long) noOfBooks * series.getLeavesPerBook())); .multiply(BigDecimal.valueOf((long) noOfBooks * series.getLeavesPerBook()));
this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount); this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount);
// reserve the charge amount on the account for the pending duration — released (then charged) on // reserve the charge amount on the account for the pending duration (a direct on-hold-funds field
// approval, or released (returned) on rejection; never left dangling // update — no transaction row, so it never shows in the passbook/transactions list) — released
final Long holdTransactionId = this.chequeBookIssuanceChargeService.placeHold(accountType, accountId, chargeAmount, // (then charged) on approval, or released (returned) on rejection; never left dangling
DateUtils.getBusinessLocalDate()); this.chequeBookIssuanceChargeService.placeHold(accountType, accountId, chargeAmount);
final Long pendingId = insertPendingRequest(ChequeBookPendingRequestType.REQUEST_BOOK, command.json(), holdTransactionId); final Long pendingId = submitPendingRequest(ChequeBookPendingRequestType.REQUEST_BOOK, command);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(pendingId).build(); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(pendingId).build();
} }
@@ -346,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 * 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. * created and immediately drawn down from available stock in the same step.
*/ */
private Long finalizeRequestChequeBook(final JsonCommand command, final Long makerId, final Long holdTransactionId) { private Long finalizeRequestChequeBook(final JsonCommand command, final Long makerId) {
final ChequeAccountType accountType = ChequeAccountType final ChequeAccountType accountType = ChequeAccountType
.fromString(command.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue())); .fromString(command.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue()));
final Long accountId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue()); final Long accountId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue());
@@ -368,7 +368,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf().multiply(BigDecimal.valueOf(totalLeaves)); 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 // 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) // against the account twice (once as "on hold", once as the charge being re-validated)
this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, holdTransactionId); this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, chargeAmount);
// authoritative re-check: rate or balance may have moved since the maker submitted this request // authoritative re-check: rate or balance may have moved since the maker submitted this request
this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount); this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount);
@@ -606,16 +606,9 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
} }
private Long insertPendingRequest(final ChequeBookPendingRequestType type, final String payloadJson) { 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 AppUser maker = this.context.authenticatedUser();
final String activityLog = appendActivityLog(null, "SUBMITTED", maker, null); final String activityLog = appendActivityLog(null, "SUBMITTED", maker, null);
final ChequeBookPendingRequest request = ChequeBookPendingRequest.submit(type, payloadJson, maker, activityLog); final ChequeBookPendingRequest request = ChequeBookPendingRequest.submit(type, payloadJson, maker, activityLog);
if (holdTransactionId != null) {
request.recordHold(holdTransactionId);
}
final ChequeBookPendingRequest saved = this.chequeBookPendingRequestRepositoryWrapper.saveAndFlush(request); final ChequeBookPendingRequest saved = this.chequeBookPendingRequestRepositoryWrapper.saveAndFlush(request);
log.info("Cheque book pending request {} ({}) submitted by {}", saved.getId(), type, maker.getUsername()); log.info("Cheque book pending request {} ({}) submitted by {}", saved.getId(), type, maker.getUsername());
return saved.getId(); return saved.getId();
@@ -642,7 +635,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
final Long createdResourceId = switch (type) { final Long createdResourceId = switch (type) {
case CREATE_SERIES -> finalizeCreateSeries(replay, request.getMakerId()); case CREATE_SERIES -> finalizeCreateSeries(replay, request.getMakerId());
case RECEIVE_STOCK -> finalizeReceiveStock(replay, request.getMakerId()); case RECEIVE_STOCK -> finalizeReceiveStock(replay, request.getMakerId());
case REQUEST_BOOK -> finalizeRequestChequeBook(replay, request.getMakerId(), request.getHoldTransactionId()); case REQUEST_BOOK -> finalizeRequestChequeBook(replay, request.getMakerId());
case DELETE_SERIES -> case DELETE_SERIES ->
finalizeDeleteSeries(replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue())); finalizeDeleteSeries(replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue()));
}; };
@@ -677,13 +670,17 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
} }
// a rejected book request returns any reserved issuance charge to the customer — never left on hold // a rejected book request returns any reserved issuance charge to the customer — never left on hold
if (request.getHoldTransactionId() != null if (ChequeBookPendingRequestType.REQUEST_BOOK.name().equals(request.getRequestType())) {
&& ChequeBookPendingRequestType.REQUEST_BOOK.name().equals(request.getRequestType())) {
final JsonCommand replay = reconstructCommand(request.getPayloadJson()); final JsonCommand replay = reconstructCommand(request.getPayloadJson());
final ChequeAccountType accountType = ChequeAccountType final ChequeAccountType accountType = ChequeAccountType
.fromString(replay.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue())); .fromString(replay.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue()));
final Long accountId = replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue()); final Long accountId = replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue());
this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, request.getHoldTransactionId()); final Long seriesId = replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue());
final Integer noOfBooks = replay.integerValueSansLocaleOfParameterNamed(ChequeBookApiJsonInputParams.NO_OF_BOOKS.getValue());
final ChequeSeries series = this.chequeSeriesRepositoryWrapper.findOneWithNotFoundDetection(seriesId);
final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf()
.multiply(BigDecimal.valueOf((long) noOfBooks * series.getLeavesPerBook()));
this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, chargeAmount);
} }
final String activityLog = appendActivityLog(request.getActivityLog(), "REJECTED", checker, reason); final String activityLog = appendActivityLog(request.getActivityLog(), "REJECTED", checker, reason);
@@ -697,7 +697,9 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL"); applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY"); applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS"); applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
applyMapped(account, fc.isChequeBook(), "CHEQUE_BOOK"); // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
// when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
// the facility enabled.
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY"); applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS"); applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY"); applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
@@ -1,239 +1,243 @@
/** /**
* Licensed to the Apache Software Foundation (ASF) under one * Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file * or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information * distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file * regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the * to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance * "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at * with the License. You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * http://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, * Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an * software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the * KIND, either express or implied. See the License for the
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
package org.apache.fineract.portfolio.savings.service; package org.apache.fineract.portfolio.savings.service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.MonthDay; import java.time.MonthDay;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig; import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
* Reads account's facility config (Yes/No per facility) and auto-applies * Reads account's facility config (Yes/No per facility) and auto-applies
* the corresponding charge from m_facility_charge_mapping. * the corresponding charge from m_facility_charge_mapping.
* No manual charge selection needed — fully automatic. * No manual charge selection needed — fully automatic.
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class FacilityChargeAutoApplyService { public class FacilityChargeAutoApplyService {
private final FacilityChargeMappingRepository mappingRepository; private final FacilityChargeMappingRepository mappingRepository;
private final SavingsAccountChargeRepository savingsAccountChargeRepository; private final SavingsAccountChargeRepository savingsAccountChargeRepository;
private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; private final SavingsAccountTransactionRepository savingsAccountTransactionRepository;
public void applyFacilityChargesOnActivation(final SavingsAccount account) { public void applyFacilityChargesOnActivation(final SavingsAccount account) {
// Use account-level facility config — this reflects what was selected at account opening time // Use account-level facility config — this reflects what was selected at account opening time
final SavingsAccountFacilityConfig config = account.getFacilityConfig(); final SavingsAccountFacilityConfig config = account.getFacilityConfig();
if (config == null) { if (config == null) {
// Fallback: use product-level config if account config not set // Fallback: use product-level config if account config not set
applyFromProductConfig(account); applyFromProductConfig(account);
return; return;
} }
final List<SavingsAccountCharge> chargesToApply = new ArrayList<>(); final List<SavingsAccountCharge> chargesToApply = new ArrayList<>();
applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getChequeBook()), "CHEQUE_BOOK", chargesToApply); // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); // the facility enabled.
applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
if (!chargesToApply.isEmpty()) { applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
savingsAccountChargeRepository.saveAll(chargesToApply);
log.info("Auto-applied {} facility charge(s) to savings account id={}", if (!chargesToApply.isEmpty()) {
chargesToApply.size(), account.getId()); savingsAccountChargeRepository.saveAll(chargesToApply);
} log.info("Auto-applied {} facility charge(s) to savings account id={}",
} chargesToApply.size(), account.getId());
}
/** }
* Collects all unpaid SavingsActivation charges on the account.
* Called after facility charges are saved and flushed, so amounts are properly initialized. /**
*/ * Collects all unpaid SavingsActivation charges on the account.
public List<SavingsAccountTransaction> collectActivationCharges(final SavingsAccount account) { * Called after facility charges are saved and flushed, so amounts are properly initialized.
final List<SavingsAccountTransaction> transactions = new ArrayList<>(); */
final LocalDate activationDate = account.getActivationDate(); public List<SavingsAccountTransaction> collectActivationCharges(final SavingsAccount account) {
final List<SavingsAccountTransaction> transactions = new ArrayList<>();
for (final SavingsAccountCharge charge : account.charges()) { final LocalDate activationDate = account.getActivationDate();
if (!charge.isSavingsActivation()) continue;
if (charge.isPaid() || charge.isWaived()) continue; for (final SavingsAccountCharge charge : account.charges()) {
if (!charge.isSavingsActivation()) continue;
final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount(); if (charge.isPaid() || charge.isWaived()) continue;
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue;
final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount();
try { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue;
final org.apache.fineract.organisation.monetary.domain.Money amountMoney =
org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount); try {
final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); final org.apache.fineract.organisation.monetary.domain.Money amountMoney =
if (txn != null) { org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount);
transactions.add(txn); final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null);
log.info("Collected activation charge '{}' ₹{} from savings account id={}", if (txn != null) {
charge.getCharge().getName(), amount, account.getId()); transactions.add(txn);
} log.info("Collected activation charge '{}' ₹{} from savings account id={}",
} catch (Exception e) { charge.getCharge().getName(), amount, account.getId());
log.warn("Could not collect activation charge '{}' from savings account id={}: {}", }
charge.getCharge() != null ? charge.getCharge().getName() : "?", account.getId(), e.getMessage()); } catch (Exception e) {
} log.warn("Could not collect activation charge '{}' from savings account id={}: {}",
} charge.getCharge() != null ? charge.getCharge().getName() : "?", account.getId(), e.getMessage());
}
if (!transactions.isEmpty()) { }
savingsAccountTransactionRepository.saveAll(transactions);
account.recalculateBalancesAfterFacilityCharges(activationDate); if (!transactions.isEmpty()) {
} savingsAccountTransactionRepository.saveAll(transactions);
return transactions; account.recalculateBalancesAfterFacilityCharges(activationDate);
} }
return transactions;
private void autoCollectSavingsActivationCharges(final SavingsAccount account, }
final List<SavingsAccountCharge> charges) {
final LocalDate activationDate = account.getActivationDate(); private void autoCollectSavingsActivationCharges(final SavingsAccount account,
final List<SavingsAccountTransaction> transactions = new ArrayList<>(); final List<SavingsAccountCharge> charges) {
final LocalDate activationDate = account.getActivationDate();
for (final SavingsAccountCharge charge : charges) { final List<SavingsAccountTransaction> transactions = new ArrayList<>();
if (!charge.isSavingsActivation()) continue;
final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount(); for (final SavingsAccountCharge charge : charges) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; if (!charge.isSavingsActivation()) continue;
final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount();
try { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue;
final Money amountMoney = Money.of(account.getCurrency(), amount);
final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); try {
if (txn != null) transactions.add(txn); final Money amountMoney = Money.of(account.getCurrency(), amount);
log.info("Auto-collected facility charge '{}' ₹{} from account id={}", final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null);
charge.getCharge().getName(), amount, account.getId()); if (txn != null) transactions.add(txn);
} catch (Exception e) { log.info("Auto-collected facility charge '{}' ₹{} from account id={}",
log.warn("Could not auto-collect facility charge '{}' from account id={}: {}", charge.getCharge().getName(), amount, account.getId());
charge.getCharge().getName(), account.getId(), e.getMessage()); } catch (Exception e) {
} log.warn("Could not auto-collect facility charge '{}' from account id={}: {}",
} charge.getCharge().getName(), account.getId(), e.getMessage());
}
if (!transactions.isEmpty()) { }
savingsAccountTransactionRepository.saveAll(transactions);
account.recalculateBalancesAfterFacilityCharges(activationDate); if (!transactions.isEmpty()) {
} savingsAccountTransactionRepository.saveAll(transactions);
} account.recalculateBalancesAfterFacilityCharges(activationDate);
}
private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled, }
final String facilityType, final List<SavingsAccountCharge> collector) {
private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled,
if (!facilityEnabled) return; final String facilityType, final List<SavingsAccountCharge> collector) {
// Skip if this charge is already applied to the account if (!facilityEnabled) return;
boolean alreadyApplied = account.charges().stream()
.anyMatch(c -> c.getCharge() != null // Skip if this charge is already applied to the account
&& c.getCharge().getName() != null boolean alreadyApplied = account.charges().stream()
&& c.getCharge().getName().equals(getChargeName(facilityType))); .anyMatch(c -> c.getCharge() != null
if (alreadyApplied) return; && c.getCharge().getName() != null
&& c.getCharge().getName().equals(getChargeName(facilityType)));
mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { if (alreadyApplied) return;
final Charge charge = mapping.getCharge();
if (charge == null) return; mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> {
final Charge charge = mapping.getCharge();
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); if (charge == null) return;
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(
charge, final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
charge.getAmount(), final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(
null, // chargeTime derived from Charge definition charge,
null, // chargeCalculation derived from Charge definition charge.getAmount(),
resolveDueDate(charge), null, // chargeTime derived from Charge definition
true, null, // chargeCalculation derived from Charge definition
feeOnMonthDay, resolveDueDate(charge),
charge.getFeeInterval()); true,
feeOnMonthDay,
accountCharge.update(account); charge.getFeeInterval());
account.charges().add(accountCharge);
collector.add(accountCharge); accountCharge.update(account);
}); account.charges().add(accountCharge);
} collector.add(accountCharge);
});
private MonthDay resolveFeeOnMonthDay(final Charge charge) { }
if (charge.getFeeOnMonthDay() != null) return charge.getFeeOnMonthDay();
if (charge.isMonthlyFee() || charge.isAnnualFee()) { private MonthDay resolveFeeOnMonthDay(final Charge charge) {
// Default: 1st of the month if (charge.getFeeOnMonthDay() != null) return charge.getFeeOnMonthDay();
return MonthDay.of(LocalDate.now().plusMonths(1).getMonth(), 1); if (charge.isMonthlyFee() || charge.isAnnualFee()) {
} // Default: 1st of the month
return null; return MonthDay.of(LocalDate.now().plusMonths(1).getMonth(), 1);
} }
return null;
private LocalDate resolveDueDate(final Charge charge) { }
if (charge.isMonthlyFee()) {
return LocalDate.now().plusMonths(1).withDayOfMonth(1); private LocalDate resolveDueDate(final Charge charge) {
} else if (charge.isAnnualFee()) { if (charge.isMonthlyFee()) {
return LocalDate.now().plusYears(1); return LocalDate.now().plusMonths(1).withDayOfMonth(1);
} } else if (charge.isAnnualFee()) {
return null; return LocalDate.now().plusYears(1);
} }
return null;
private String getChargeName(final String facilityType) { }
return mappingRepository.findByFacilityType(facilityType)
.map(FacilityChargeMapping::getChargeName) private String getChargeName(final String facilityType) {
.orElse(""); return mappingRepository.findByFacilityType(facilityType)
} .map(FacilityChargeMapping::getChargeName)
.orElse("");
private void applyFromProductConfig(final SavingsAccount account) { }
final SavingsProductFacilityConfig config = account.savingsProduct() != null
? account.savingsProduct().getFacilityConfig() : null; private void applyFromProductConfig(final SavingsAccount account) {
if (config == null) return; final SavingsProductFacilityConfig config = account.savingsProduct() != null
? account.savingsProduct().getFacilityConfig() : null;
final List<SavingsAccountCharge> chargesToApply = new ArrayList<>(); if (config == null) return;
applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); final List<SavingsAccountCharge> chargesToApply = new ArrayList<>();
applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getChequeBook()), "CHEQUE_BOOK", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); // the facility enabled.
applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
if (!chargesToApply.isEmpty()) { applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
savingsAccountChargeRepository.saveAll(chargesToApply); applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
} applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
} applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
} if (!chargesToApply.isEmpty()) {
savingsAccountChargeRepository.saveAll(chargesToApply);
}
}
}