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
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:
co-authored by
Claude Sonnet 5
parent
5ed95c27e8
commit
383dfdc6e9
+243
-239
@@ -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<SavingsAccountCharge> 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<SavingsAccountTransaction> collectActivationCharges(final SavingsAccount account) {
|
||||
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> charges) {
|
||||
final LocalDate activationDate = account.getActivationDate();
|
||||
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> 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<SavingsAccountCharge> 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<SavingsAccountCharge> 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<SavingsAccountTransaction> collectActivationCharges(final SavingsAccount account) {
|
||||
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> charges) {
|
||||
final LocalDate activationDate = account.getActivationDate();
|
||||
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> 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<SavingsAccountCharge> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user