charges add module add in the saving account and current account

This commit is contained in:
Nitesh Yadav
2026-05-16 21:20:30 +05:30
parent 28fa6d9615
commit d0c8be4ee9
29 changed files with 91869 additions and 2607 deletions
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -97,7 +98,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" journalEntry.type_enum as entryType,journalEntry.amount as amount, journalEntry.transaction_id as transactionId,")
.append(" journalEntry.entity_type_enum as entityType, journalEntry.entity_id as entityId, creatingUser.id as createdByUserId, ")
.append(" creatingUser.username as createdByUserName, journalEntry.description as comments, ")
.append(" journalEntry.submitted_on_date as submittedOnDate, journalEntry.reversed as reversed, ")
.append(" journalEntry.created_on_utc as submittedOnDate, journalEntry.reversed as reversed, ")
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner ");
@@ -157,7 +158,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
final Long entityId = JdbcSupport.getLong(rs, "entityId");
final Long createdByUserId = rs.getLong("createdByUserId");
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
final OffsetDateTime submittedOnDate = rs.getObject("submittedOnDate", OffsetDateTime.class);
final String createdByUserName = rs.getString("createdByUserName");
final String comments = rs.getString("comments");
final Boolean reversed = rs.getBoolean("reversed");
@@ -63,7 +63,154 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
if (rows.isEmpty()) {
throw new CurrentAccountNotFoundException(accountId);
}
return buildStructuredResponse(rows.get(0));
final Map<String, Object> response = buildStructuredResponse(rows.get(0));
response.put("charges", retrieveCharges(accountId));
response.put("transactions", retrieveTransactions(accountId));
return response;
}
private List<Map<String, Object>> retrieveTransactions(Long accountId) {
final String sql =
"SELECT t.id, t.transaction_type_enum, t.transaction_date, t.amount, "
+ "t.running_balance_derived, t.is_reversed, t.is_reversal, t.ref_no, t.remarks, "
+ "t.submitted_on_date "
+ "FROM m_current_account_transaction t "
+ "WHERE t.current_account_id = ? "
+ "ORDER BY t.transaction_date ASC, t.id ASC";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> t = new LinkedHashMap<>();
t.put("id", rs.getLong("id"));
final int txnTypeEnum = rs.getInt("transaction_type_enum");
t.put("transactionType", buildTransactionType(txnTypeEnum));
final java.time.LocalDate txnDate = rs.getObject("transaction_date", java.time.LocalDate.class);
if (txnDate != null) {
t.put("date", new int[]{txnDate.getYear(), txnDate.getMonthValue(), txnDate.getDayOfMonth()});
} else {
t.put("date", null);
}
t.put("amount", rs.getBigDecimal("amount"));
t.put("runningBalance", rs.getBigDecimal("running_balance_derived"));
t.put("reversed", rs.getBoolean("is_reversed"));
t.put("isReversal", rs.getBoolean("is_reversal"));
t.put("refNo", rs.getString("ref_no"));
t.put("remarks", rs.getString("remarks"));
final java.time.LocalDate submittedOn = rs.getObject("submitted_on_date", java.time.LocalDate.class);
if (submittedOn != null) {
t.put("submittedOnDate", new int[]{submittedOn.getYear(), submittedOn.getMonthValue(), submittedOn.getDayOfMonth()});
} else {
t.put("submittedOnDate", null);
}
return t;
}, accountId);
}
private Map<String, Object> buildTransactionType(int typeEnum) {
final Map<String, Object> tt = new LinkedHashMap<>();
tt.put("id", typeEnum);
tt.put("deposit", typeEnum == 1);
tt.put("withdrawal", typeEnum == 2);
tt.put("interestPosting", typeEnum == 3);
tt.put("feeDeduction", typeEnum == 4 || typeEnum == 7);
tt.put("overdraftInterest", false);
tt.put("withholdTax", false);
tt.put("initiateTransfer", false);
tt.put("approveTransfer", false);
tt.put("withdrawTransfer", false);
tt.put("rejectTransfer", false);
tt.put("writtenoff", false);
tt.put("overdraftFee", false);
tt.put("escheat", false);
tt.put("amountHold", false);
tt.put("amountRelease", false);
tt.put("accrual", false);
switch (typeEnum) {
case 1 -> { tt.put("code", "savingsTransactionType.deposit"); tt.put("value", "Deposit"); }
case 2 -> { tt.put("code", "savingsTransactionType.withdrawal"); tt.put("value", "Withdrawal"); }
case 3 -> { tt.put("code", "savingsTransactionType.interestPosting"); tt.put("value", "Interest Posting"); }
case 4 -> { tt.put("code", "savingsTransactionType.feeDeduction"); tt.put("value", "Fee Deduction"); }
case 7 -> { tt.put("code", "savingsTransactionType.payCharge"); tt.put("value", "Charge Deduction"); }
default -> { tt.put("code", "savingsTransactionType.deposit"); tt.put("value", "Deposit"); }
}
return tt;
}
private List<Map<String, Object>> retrieveCharges(Long accountId) {
final String sql =
"SELECT cac.id, cac.charge_id, ch.name AS charge_name, "
+ "cac.charge_time_enum, cac.charge_due_date, cac.fee_on_month, cac.fee_on_day, "
+ "cac.fee_interval, cac.charge_calculation_enum, "
+ "cac.amount, cac.amount_paid_derived, cac.amount_waived_derived, "
+ "cac.amount_writtenoff_derived, cac.amount_outstanding_derived, "
+ "cac.is_paid_derived, cac.waived, cac.is_penalty, cac.is_active "
+ "FROM m_current_account_charge cac "
+ "JOIN m_charge ch ON ch.id = cac.charge_id "
+ "WHERE cac.current_account_id = ? AND cac.is_active = true "
+ "ORDER BY cac.id";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> c = new LinkedHashMap<>();
c.put("id", rs.getLong("id"));
c.put("chargeId", rs.getLong("charge_id"));
c.put("name", rs.getString("charge_name"));
final int chargeTimeEnum = rs.getInt("charge_time_enum");
c.put("chargeTimeType", buildEnumOption(chargeTimeEnum, chargeTimeValue(chargeTimeEnum)));
c.put("chargeDueDate", rs.getDate("charge_due_date") != null ? rs.getDate("charge_due_date").toLocalDate().toString() : null);
c.put("feeOnMonthDay", buildFeeOnMonthDay(rs.getObject("fee_on_month"), rs.getObject("fee_on_day")));
c.put("feeInterval", rs.getObject("fee_interval"));
final int chargeCalcEnum = rs.getInt("charge_calculation_enum");
c.put("chargeCalculationType", buildEnumOption(chargeCalcEnum, chargeCalcValue(chargeCalcEnum)));
c.put("amount", rs.getBigDecimal("amount"));
c.put("amountPaid", rs.getBigDecimal("amount_paid_derived"));
c.put("amountWaived", rs.getBigDecimal("amount_waived_derived"));
c.put("amountWrittenOff", rs.getBigDecimal("amount_writtenoff_derived"));
c.put("amountOutstanding", rs.getBigDecimal("amount_outstanding_derived"));
c.put("paid", rs.getBoolean("is_paid_derived"));
c.put("waived", rs.getBoolean("waived"));
c.put("penalty", rs.getBoolean("is_penalty"));
c.put("isActive", rs.getBoolean("is_active"));
return c;
}, accountId);
}
private Map<String, Object> buildFeeOnMonthDay(Object month, Object day) {
if (month == null || day == null) return null;
final Map<String, Object> m = new LinkedHashMap<>();
m.put("month", month);
m.put("day", day);
return m;
}
private String chargeTimeValue(int id) {
return switch (id) {
case 1 -> "Disbursement";
case 2 -> "Specified due date";
case 3 -> "Savings Activation";
case 4 -> "Withdrawal Fee";
case 5 -> "Annual Fee";
case 6 -> "Annual Fee";
case 7 -> "Monthly Fee";
case 8 -> "Overdue Fee";
default -> "Specified due date";
};
}
private String chargeCalcValue(int id) {
return switch (id) {
case 1 -> "Flat";
case 2 -> "% Amount";
case 3 -> "% Loan Amount + Interest";
case 4 -> "% Interest";
default -> "Flat";
};
}
@Override
@@ -71,7 +218,10 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
List<Map<String, Object>> rows = jdbcTemplate.query(BASE_SQL, new FlatRowMapper());
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> row : rows) {
result.add(buildStructuredResponse(row));
final Map<String, Object> response = buildStructuredResponse(row);
final Long id = ((Number) row.get("id")).longValue();
response.put("charges", retrieveCharges(id));
result.add(response);
}
return result;
}
@@ -44,6 +44,14 @@ import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWriteP
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountNumberGenerator;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingRuleType;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.springframework.stereotype.Service;
@@ -61,6 +69,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
private final OfficeRepositoryWrapper officeRepository;
private final CurrentAccountNumberGenerator accountNumberGenerator;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository;
private final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final JournalEntryRepository journalEntryRepository;
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
@Override
@Transactional
@@ -114,6 +126,26 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
product.getInterestCalculateSelector(), product.getAveragePeriod(),
product.getAvgBalanceCalcPeriod());
// Copy product-level fields that are not in createNew()
account.setMinRequiredOpeningBalance(product.getMinRequiredOpeningBalance());
account.setMinRequiredBalance(product.getMinRequiredBalance());
account.setEnforceMinRequiredBalance(product.isEnforceMinRequiredBalance());
account.setLockinPeriodFrequency(product.getLockinPeriodFrequency());
account.setLockinPeriodFrequencyEnum(product.getLockinPeriodFrequencyType());
account.setNominalAnnualInterestRateOverdraft(product.getNominalAnnualInterestRateOverdraft());
account.setWithdrawalFeeForTransfer(product.isWithdrawalFeeApplicableForTransfer());
account.setMinOverdraftForInterestCalculation(product.getMinOverdraftForInterestCalculation());
account.setFrequencyFixDay(product.getFrequencyFixDay());
account.setEnableLockinPeriod(product.isEnableLockinPeriod());
account.setBalanceFrequency(product.getBalanceFrequency());
account.setAccountClosedZeroBalanceInDayClosing(product.getAccountClosedZeroBalanceInDayClosing());
account.setValidForPeriod(product.getValidForPeriod());
account.setOdAccountClosedZeroBalanceInDayClosing(product.isOdAccountClosedZeroBalanceInDayClosing());
account.setLienAllowed(product.isLienAllowed());
account.setMaxAllowedLienLimit(product.getMaxAllowedLienLimit());
account.setMinBalanceForInterestCalculation(product.getMinBalanceForInterestCalculation());
account.setWithholdTax(product.withHoldTax());
if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) {
account.setEstablishmentDate(parseDate(json.get("establishmentDate").getAsString(), dateFormat, locale));
}
@@ -141,7 +173,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
if (introducerClientId != null) account.setIntroducerClientId(introducerClientId);
if (json.has("externalId") && !json.get("externalId").isJsonNull()) {
account.setExternalId(json.get("externalId").getAsString());
final String extId = json.get("externalId").getAsString().trim();
account.setExternalId(extId.isEmpty() ? null : extId);
}
if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) {
account.setAccountStatus(json.get("accountStatus").getAsString());
@@ -221,8 +254,73 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
final JsonObject json = command.parsedJson().getAsJsonObject();
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
if (activatedOnDate == null) activatedOnDate = LocalDate.now();
// Validate minimum opening balance before activation
java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO;
if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) {
try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {}
}
final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance();
if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0
&& openingBalanceValue.compareTo(minRequired) < 0) {
throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException(
java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")",
"validation.msg.currentaccount.openingBalance.less.than.min.required",
openingBalanceValue, minRequired)));
}
account.activate(activatedOnDate);
applyFacilityChargesOnActivation(account);
java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO;
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns = new java.util.ArrayList<>();
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
// Opening balance deposit transaction
if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) {
try {
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE");
deposit.setRunningBalanceDerived(openingBalanceValue);
account.setTotalDepositsDerived(openingBalanceValue);
currentAccountTransactionRepository.saveAndFlush(deposit);
currentBalance = openingBalanceValue;
depositTxns.add(deposit);
log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId);
} catch (Exception e) {
log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage());
}
}
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : account.getCharges()) {
if (charge.getChargeTimeEnum() != null && charge.getChargeTimeEnum() == 3
&& charge.getAmount() != null
&& charge.getAmount().compareTo(java.math.BigDecimal.ZERO) > 0
&& charge.getCharge() != null) {
charge.setAmountPaidDerived(charge.getAmount());
charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO);
charge.setPaid(true);
currentBalance = currentBalance.subtract(charge.getAmount());
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.chargeDeduction(account, account.getOffice(), charge.getAmount(), activatedOnDate);
chargeTxn.setRunningBalanceDerived(currentBalance);
currentAccountTransactionRepository.saveAndFlush(chargeTxn);
chargeEntries.add(new ChargeGlEntry(chargeTxn, charge));
log.info("Auto-deducted activation charge '{}' {} from account id={}, running balance={}",
charge.getCharge().getName(), charge.getAmount(), accountId, currentBalance);
}
}
account.setAccountBalanceDerived(currentBalance);
currentAccountRepository.saveAndFlush(account);
// Post GL journal entries — deposits in batch, each charge individually
postGlEntries(account, depositTxns, chargeEntries);
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
}
@@ -396,4 +494,172 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
}
}
private void applyFacilityChargesOnActivation(final CurrentAccount account) {
// Use account-level facility config — reflects what was selected at account opening time
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config =
account.getFacilityConfig();
log.info("[FacilityCharge] Activating account id={}, facilityConfig={}",
account.getId(), config != null ? "FOUND" : "NULL");
if (config == null) {
// Fallback: use product-level facility config
log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId());
config = currentProductRepository.findById(account.getProductId())
.map(p -> {
final var pc = p.getFacilityConfig();
if (pc == null) {
log.info("[FacilityCharge] Product has no facility config either");
return null;
}
// Map product config to account config fields inline
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac =
new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig();
ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility()));
ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility()));
ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility()));
ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility()));
ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook()));
ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility()));
ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts()));
ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility()));
ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover()));
ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility()));
ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility()));
ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility()));
ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility()));
ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility()));
ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc()));
ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard()));
return ac;
}).orElse(null);
}
if (config == null) {
log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId());
return;
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config;
log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}",
fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(),
fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(),
fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(),
fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility());
applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY");
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
applyMapped(account, fc.isChequeBook(), "CHEQUE_BOOK");
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER");
applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY");
applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY");
applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY");
applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP");
applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY");
applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC");
applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD");
}
// Wrapper to carry a charge transaction together with its charge entity for GL posting
private record ChargeGlEntry(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {}
// entity type for savings/current accounts in acc_gl_journal_entry
private static final int ENTITY_TYPE_SAVINGS = 2;
private void postGlEntries(final CurrentAccount account,
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns,
final java.util.List<ChargeGlEntry> chargeEntries) {
try {
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
if (product == null) return;
final Integer accountingRule = product.getAccountingType();
if (accountingRule == null || accountingRule.equals(AccountingRuleType.NONE.getValue())) {
log.debug("[GL] Product {} accounting=NONE — skipping", account.getProductId());
return;
}
final GLAccount cashAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue());
final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue());
final GLAccount feeIncomeAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue());
if (cashAccount == null || controlAccount == null) {
log.warn("[GL] Missing GL account mappings for product id={} — skipping GL posting", account.getProductId());
return;
}
final String transactionRef = "CA" + account.getId();
// Deposits: DR SAVINGS_REFERENCE (cash in), CR SAVINGS_CONTROL (liability)
if (depositTxns != null) {
for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn : depositTxns) {
if (txn.getId() == null || txn.getAmount() == null) continue;
postEntry(account, cashAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), transactionRef);
postEntry(account, controlAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), transactionRef);
log.info("[GL] Deposit GL posted for account id={}, amount={}", account.getId(), txn.getAmount());
}
}
// Charges: DR SAVINGS_CONTROL (liability reduced), CR INCOME_FROM_FEES
if (chargeEntries != null && feeIncomeAccount != null) {
for (final ChargeGlEntry entry : chargeEntries) {
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn = entry.txn();
if (txn.getId() == null || txn.getAmount() == null) continue;
postEntry(account, controlAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), transactionRef);
postEntry(account, feeIncomeAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), transactionRef);
log.info("[GL] Charge GL posted for account id={}, charge={}, amount={}", account.getId(), entry.charge().getCharge().getName(), txn.getAmount());
}
}
} catch (Exception e) {
log.error("[GL] Failed GL posting for current account id={}: {}", account.getId(), e.getMessage(), e);
}
}
private GLAccount findGlAccount(final Long productId, final int financialAccountType) {
final org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping mapping =
productToGLAccountMappingRepository.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(
productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null);
return mapping != null ? mapping.getGlAccount() : null;
}
private void postEntry(final CurrentAccount account, final GLAccount glAccount,
final JournalEntryType entryType, final java.math.BigDecimal amount,
final LocalDate date, final String transactionRef) {
final JournalEntry entry = JournalEntry.createNew(
account.getOffice(), null, glAccount,
account.getCurrencyCode(), transactionRef,
false, date, entryType, amount,
"Current Account activation",
ENTITY_TYPE_SAVINGS, account.getId(),
null, null, null, null, null);
journalEntryRepository.save(entry);
}
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
if (!enabled) {
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
return;
}
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType);
return;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType);
return;
}
log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType);
account.getCharges().add(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge
.createFromFacility(account, charge));
}
}
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -165,6 +166,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
private final GSIMRepositoy gsimRepository;
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
private final ErrorHandler errorHandler;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
@Transactional
@Override
@@ -199,6 +201,24 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Validate minimum opening balance
BigDecimal openingBalanceForValidation = BigDecimal.ZERO;
if (command.parameterExists("openingBalance")) {
final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance");
if (ob != null) openingBalanceForValidation = ob;
}
final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance();
if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0
&& openingBalanceForValidation.compareTo(minRequiredOpening) < 0) {
final List<ApiParameterError> errors = new ArrayList<>();
errors.add(ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")",
"validation.msg.savingsaccount.openingBalance.less.than.min.required",
openingBalanceForValidation, minRequiredOpening));
throw new PlatformApiDataValidationException(errors);
}
checkClientOrGroupActive(account);
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
@@ -214,6 +234,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
// Auto-apply facility charges based on product facility config
facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
// Collect any newly added facility activation charges (after flush so amountOutstanding is set)
// payCharge() internally adds transactions to account.transactions via handleChargeTransactions
// Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting
facilityChargeAutoApplyService.collectActivationCharges(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@@ -98,6 +98,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimit;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.DepositAccountOnHoldTransactionReadPlatformService;
@@ -423,7 +424,7 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler) {
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
@@ -431,7 +432,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
errorHandler);
errorHandler, facilityChargeAutoApplyService);
}
@Bean