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 985128d34..5bc06507c 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,14 +85,6 @@ 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(); @@ -107,10 +99,6 @@ 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 index 1a27facd6..d13f29caf 100644 --- 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 @@ -168,47 +168,45 @@ public class ChequeBookIssuanceChargeService { } /** - * 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). + * Reserves {@code chargeAmount} on the account by directly incrementing its on-hold-funds field — a + * plain entity mutation, not Fineract's native hold-amount command, so it creates no transaction row + * and never appears in the passbook/transactions list. 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. */ - public Long placeHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount, - final LocalDate today) { + public void placeHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount) { 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 - * charge rate was zero at submit time) — becomes a no-op. + * Releases a previously-placed hold — reverses the same field increment, again with no transaction row. + * 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) { - if (holdTransactionId == null) { + public void releaseHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount) { + if (chargeAmount == null || chargeAmount.signum() <= 0) { 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); + if (accountType == ChequeAccountType.SAVINGS) { + final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId); + account.releaseOnHoldAmount(chargeAmount); + this.savingsAccountRepositoryWrapper.saveAndFlush(account); + } 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); + } } /** 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 884b90081..e7e9e0b6a 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 @@ -330,11 +330,11 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo 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); + // reserve the charge amount on the account for the pending duration (a direct on-hold-funds field + // update — no transaction row, so it never shows in the passbook/transactions list) — released + // (then charged) on approval, or released (returned) on rejection; never left dangling + this.chequeBookIssuanceChargeService.placeHold(accountType, accountId, chargeAmount); + final Long pendingId = submitPendingRequest(ChequeBookPendingRequestType.REQUEST_BOOK, command); 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 * 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 .fromString(command.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.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)); // 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); + this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, chargeAmount); // authoritative re-check: rate or balance may have moved since the maker submitted this request this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount); @@ -606,16 +606,9 @@ 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(); @@ -642,7 +635,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(), request.getHoldTransactionId()); + case REQUEST_BOOK -> finalizeRequestChequeBook(replay, request.getMakerId()); case DELETE_SERIES -> 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 - if (request.getHoldTransactionId() != null - && ChequeBookPendingRequestType.REQUEST_BOOK.name().equals(request.getRequestType())) { + if (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 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index cf9f24db0..873bf3962 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -697,7 +697,9 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL"); applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY"); 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.isSmsAlerts(), "SMS_ALERTS"); applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY"); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java index e159576cf..4488167b9 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java @@ -1,239 +1,243 @@ -/** - * 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.savings.service; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.MonthDay; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.portfolio.charge.domain.Charge; -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.SavingsAccountFacilityConfig; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; -import org.springframework.stereotype.Service; - -/** - * Reads account's facility config (Yes/No per facility) and auto-applies - * the corresponding charge from m_facility_charge_mapping. - * No manual charge selection needed — fully automatic. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class FacilityChargeAutoApplyService { - - private final FacilityChargeMappingRepository mappingRepository; - private final SavingsAccountChargeRepository savingsAccountChargeRepository; - private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; - - public void applyFacilityChargesOnActivation(final SavingsAccount account) { - // Use account-level facility config — this reflects what was selected at account opening time - final SavingsAccountFacilityConfig config = account.getFacilityConfig(); - - if (config == null) { - // Fallback: use product-level config if account config not set - applyFromProductConfig(account); - return; - } - - final List chargesToApply = new ArrayList<>(); - - 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.getUpiFacility()), "UPI_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getChequeBook()), "CHEQUE_BOOK", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",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); - 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. - */ - public List collectActivationCharges(final SavingsAccount account) { - final List transactions = new ArrayList<>(); - final LocalDate activationDate = account.getActivationDate(); - - for (final SavingsAccountCharge charge : account.charges()) { - if (!charge.isSavingsActivation()) continue; - if (charge.isPaid() || charge.isWaived()) continue; - - final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; - - try { - final org.apache.fineract.organisation.monetary.domain.Money amountMoney = - org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount); - final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); - if (txn != null) { - transactions.add(txn); - log.info("Collected activation charge '{}' ₹{} from savings account id={}", - charge.getCharge().getName(), amount, account.getId()); - } - } 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); - } - return transactions; - } - - private void autoCollectSavingsActivationCharges(final SavingsAccount account, - final List charges) { - final LocalDate activationDate = account.getActivationDate(); - final List transactions = new ArrayList<>(); - - for (final SavingsAccountCharge charge : charges) { - if (!charge.isSavingsActivation()) continue; - final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; - - try { - final Money amountMoney = Money.of(account.getCurrency(), amount); - final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); - if (txn != null) transactions.add(txn); - log.info("Auto-collected facility charge '{}' ₹{} from account id={}", - charge.getCharge().getName(), amount, account.getId()); - } 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); - } - } - - private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled, - final String facilityType, final List collector) { - - if (!facilityEnabled) return; - - // Skip if this charge is already applied to the account - boolean alreadyApplied = account.charges().stream() - .anyMatch(c -> c.getCharge() != null - && c.getCharge().getName() != null - && c.getCharge().getName().equals(getChargeName(facilityType))); - if (alreadyApplied) return; - - mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { - final Charge charge = mapping.getCharge(); - if (charge == null) return; - - final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); - final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount( - charge, - charge.getAmount(), - null, // chargeTime derived from Charge definition - null, // chargeCalculation derived from Charge definition - resolveDueDate(charge), - true, - feeOnMonthDay, - charge.getFeeInterval()); - - 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()) { - // Default: 1st of the month - 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); - } else if (charge.isAnnualFee()) { - return LocalDate.now().plusYears(1); - } - return null; - } - - private String getChargeName(final String facilityType) { - return mappingRepository.findByFacilityType(facilityType) - .map(FacilityChargeMapping::getChargeName) - .orElse(""); - } - - private void applyFromProductConfig(final SavingsAccount account) { - final SavingsProductFacilityConfig config = account.savingsProduct() != null - ? account.savingsProduct().getFacilityConfig() : null; - if (config == null) return; - - final List chargesToApply = new ArrayList<>(); - 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.getUpiFacility()), "UPI_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getChequeBook()), "CHEQUE_BOOK", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",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); - } - } -} +/** + * 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.savings.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.MonthDay; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.charge.domain.Charge; +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.SavingsAccountFacilityConfig; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; +import org.springframework.stereotype.Service; + +/** + * Reads account's facility config (Yes/No per facility) and auto-applies + * the corresponding charge from m_facility_charge_mapping. + * No manual charge selection needed — fully automatic. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FacilityChargeAutoApplyService { + + private final FacilityChargeMappingRepository mappingRepository; + private final SavingsAccountChargeRepository savingsAccountChargeRepository; + private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; + + public void applyFacilityChargesOnActivation(final SavingsAccount account) { + // Use account-level facility config — this reflects what was selected at account opening time + final SavingsAccountFacilityConfig config = account.getFacilityConfig(); + + if (config == null) { + // Fallback: use product-level config if account config not set + applyFromProductConfig(account); + return; + } + + final List chargesToApply = new ArrayList<>(); + + 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.getUpiFacility()), "UPI_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); + // 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. + applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",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); + 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. + */ + public List collectActivationCharges(final SavingsAccount account) { + final List transactions = new ArrayList<>(); + final LocalDate activationDate = account.getActivationDate(); + + for (final SavingsAccountCharge charge : account.charges()) { + if (!charge.isSavingsActivation()) continue; + if (charge.isPaid() || charge.isWaived()) continue; + + final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; + + try { + final org.apache.fineract.organisation.monetary.domain.Money amountMoney = + org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount); + final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); + if (txn != null) { + transactions.add(txn); + log.info("Collected activation charge '{}' ₹{} from savings account id={}", + charge.getCharge().getName(), amount, account.getId()); + } + } 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); + } + return transactions; + } + + private void autoCollectSavingsActivationCharges(final SavingsAccount account, + final List charges) { + final LocalDate activationDate = account.getActivationDate(); + final List transactions = new ArrayList<>(); + + for (final SavingsAccountCharge charge : charges) { + if (!charge.isSavingsActivation()) continue; + final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; + + try { + final Money amountMoney = Money.of(account.getCurrency(), amount); + final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); + if (txn != null) transactions.add(txn); + log.info("Auto-collected facility charge '{}' ₹{} from account id={}", + charge.getCharge().getName(), amount, account.getId()); + } 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); + } + } + + private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled, + final String facilityType, final List collector) { + + if (!facilityEnabled) return; + + // Skip if this charge is already applied to the account + boolean alreadyApplied = account.charges().stream() + .anyMatch(c -> c.getCharge() != null + && c.getCharge().getName() != null + && c.getCharge().getName().equals(getChargeName(facilityType))); + if (alreadyApplied) return; + + mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { + final Charge charge = mapping.getCharge(); + if (charge == null) return; + + final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); + final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount( + charge, + charge.getAmount(), + null, // chargeTime derived from Charge definition + null, // chargeCalculation derived from Charge definition + resolveDueDate(charge), + true, + feeOnMonthDay, + charge.getFeeInterval()); + + 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()) { + // Default: 1st of the month + 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); + } else if (charge.isAnnualFee()) { + return LocalDate.now().plusYears(1); + } + return null; + } + + private String getChargeName(final String facilityType) { + return mappingRepository.findByFacilityType(facilityType) + .map(FacilityChargeMapping::getChargeName) + .orElse(""); + } + + private void applyFromProductConfig(final SavingsAccount account) { + final SavingsProductFacilityConfig config = account.savingsProduct() != null + ? account.savingsProduct().getFacilityConfig() : null; + if (config == null) return; + + final List chargesToApply = new ArrayList<>(); + 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.getUpiFacility()), "UPI_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); + // 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. + applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",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); + } + } +}