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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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