From 3a320b9c095d1986dfc115aabb21e915c2dae152 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 16 Oct 2026 12:30:44 +0530 Subject: [PATCH] feat(fixed-deposits): FD funding rules and no FD withdrawals from clerk/cashier requests New FixedDepositFundingValidator, used on every path money can reach an FD: - an FD is funded once, with exactly its principal: it must be active and not matured, hold no deposit yet, and the amount must equal its deposit amount; - when funded from a Savings/Current account (clerk Bank/Fund Transfer), that account must be a Savings or Current account, active, not debit-frozen (sub-status BLOCK / BLOCK_DEBIT), out of lock-in, belong to the FD's client or be held jointly with them, and have enough available balance after holds, lien, clearing holds and minimum balance (plus overdraft for current accounts). Applied in: - FD deposit command (counter / FD screen / approved cashier deposit requests); - cashier deposit request submit, so the counter sees the reason at once; - clerk Disbursement / Collection / Fund Transfer / Bank Transfer, at submit and again at approval before the request is locked; one request funds FDs of one customer, an FD appears once, and an FD can never be a debit leg. After posting, each funded FD's maturity date and amount are recomputed (new DepositAccountWritePlatformService.recalculateFixedDepositMaturity), since the direct book credit does not do it; - clerk withdrawal request submit refuses an FD: money leaves an FD only through its maturity or premature closure. Previously a direct book debit in Collection / Bank Transfer could take money out of an FD, bypassing the FD's own withdrawal refusal, and an FD could be credited any number of times for any amount. Co-Authored-By: Claude Opus 5.5 --- .../api/CashierDepositRequestApiResource.java | 7 + .../ClerkDisbursementRequestApiResource.java | 81 ++++++ .../ClerkWithdrawalRequestApiResource.java | 8 + ...WritePlatformServiceJpaRepositoryImpl.java | 17 ++ .../service/FixedDepositFundingValidator.java | 238 ++++++++++++++++++ .../DepositAccountWritePlatformService.java | 7 + 6 files changed, 358 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 9ffd57359..436b435ef 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.slf4j.Logger; @@ -98,6 +99,7 @@ public class CashierDepositRequestApiResource { private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final VoucherService voucherService; + private final FixedDepositFundingValidator fixedDepositFundingValidator; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ── @@ -155,6 +157,11 @@ public class CashierDepositRequestApiResource { throw apiError(400, "Amount must be greater than zero"); } kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT); + // An FD is funded once, with exactly its principal. Told here at the counter rather than when the + // manager approves; the FD deposit command checks the same again at posting. + if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) { + fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount); + } validateDenominationTotal(denominationData, amount); validateDdLimit(ddFaceValue); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 680c0d895..c5c065e36 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; @@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final FineractRequestContextHolder fineractRequestContextHolder; private final VoucherService voucherService; + private final FixedDepositFundingValidator fixedDepositFundingValidator; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ──────── @@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource { } } + // Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account. + validateFixedDepositLegs(creditEntries, requestType); + // Validate balance when both DR and CR entries present (collection mode) if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0 && totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) { @@ -491,6 +498,10 @@ public class ClerkDisbursementRequestApiResource { rejectRecurringDepositLeg(entry, entryType); } } + // Re-checked here, not only at submit: another request (or a counter deposit) may have funded the + // same FD while this one waited, and a refusal now still leaves the request PENDING. + final Set fundedFixedDeposits = validateFixedDepositLegs(creditEntries, + record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT"); // Atomic PENDING → PROCESSING lock: prevents two managers from concurrently // posting duplicate Fineract transactions for the same request. @@ -773,6 +784,12 @@ public class ClerkDisbursementRequestApiResource { } } // end else (disbursement/collection mode) + // A funded FD's maturity date and amount were projected off its principal with no money in it; + // the direct credit above does not recompute them, so do it now that the money has landed. + for (final Long fdId : fundedFixedDeposits) { + depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId); + } + } catch (Exception ex) { // Compensate: undo all posted Fineract transactions in reverse order (best-effort). // This restores account balances even when a later entry fails mid-processing. @@ -1223,6 +1240,70 @@ public class ClerkDisbursementRequestApiResource { } } + /** + * Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label. + *
    + *
  • An FD is never debited here: money leaves an FD only through its maturity or premature closure. The + * direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.
  • + *
  • An FD credit is its first and only funding, for exactly its principal, and an FD appears once.
  • + *
  • The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund + * them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is + * the bank's own money and has no customer to compare.
  • + *
+ * + * @return the ids of the FDs this request funds, for the maturity recalculation after posting + */ + private Set validateFixedDepositLegs(final JsonArray entries, final String requestType) { + final Set fdIds = new LinkedHashSet<>(); + Long firstFdId = null; + Long fdClientId = null; + for (final JsonElement el : entries) { + final JsonObject entry = el.getAsJsonObject(); + final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; + if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId") + || entry.get("savingsAccountId").isJsonNull()) { + continue; + } + final Long accountId = entry.get("savingsAccountId").getAsLong(); + if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) { + continue; + } + final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId); + if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) { + throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only" + + " through its maturity or premature closure."); + } + if (!fdIds.add(accountId)) { + throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once," + + " with its full principal in one entry."); + } + final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null); + final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount); + if (firstFdId == null) { + firstFdId = accountId; + fdClientId = clientId; + } else if (!Objects.equals(fdClientId, clientId)) { + throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer" + + " only. Raise a separate request for each customer."); + } + } + if (fdIds.isEmpty()) { + return fdIds; + } + for (final JsonElement el : entries) { + final JsonObject entry = el.getAsJsonObject(); + final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; + if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type) + || !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) { + continue; + } + final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null); + fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(), + "CURRENT".equals(type), amount); + } + return fdIds; + } + /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ private static final int SAVINGS_STATUS_ACTIVE = 300; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 6455ba4b6..e18c3092d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -57,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.slf4j.Logger; @@ -85,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource { private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final VoucherService voucherService; + private final FixedDepositFundingValidator fixedDepositFundingValidator; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ───────── @@ -127,6 +129,12 @@ public class ClerkWithdrawalRequestApiResource { } else if (savingsAccountId == null) { throw apiError(400, "Savings account id is required for a withdrawal request"); } + // Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip. + // Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account. + if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) { + throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from." + + " Money leaves a fixed deposit only through its maturity or premature closure."); + } if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw apiError(400, "Amount must be greater than zero"); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index aa9afdb81..dccc11ba5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -392,6 +392,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate"); final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount"); + // An FD is funded once, with exactly its principal (see FixedDepositFundingValidator). + new FixedDepositFundingValidator(this.jdbcTemplate).validateFirstFunding(savingsId, transactionAmount); + final Map changes = new LinkedHashMap<>(); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); this.denominationBreakdownWriter.validatePreviousDayClosed(); @@ -422,6 +425,20 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo } + @Transactional + @Override + public void recalculateFixedDepositMaturity(final Long savingsId) { + final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId, + DepositAccountType.FIXED_DEPOSIT); + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + final boolean isPreMatureClosure = false; + account.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + this.savingAccountRepositoryWrapper.saveAndFlush(account); + } + @Transactional @Override public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java new file mode 100644 index 000000000..808067ccd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositFundingValidator.java @@ -0,0 +1,238 @@ +/** + * 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.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The rules a fixed deposit is funded under, shared by every path money can reach an FD by: the FD deposit + * command itself (cash at the counter, approved cashier deposit requests) and the clerk's Bank Transfer, + * Fund Transfer and Disbursement requests, which credit the account directly. + * + *

+ * An FD opens with no money in it and is funded exactly once, with exactly its principal. A second credit + * would silently change the principal the maturity was agreed on; anything more goes into a new FD (several + * FDs on the same product are allowed). Money never leaves an FD through these paths either - only through + * its maturity or premature closure. + * + *

+ * When the money comes from the customer's own Savings or Current account, that account must be able to pay + * it: active, not debit-frozen, out of any lock-in, belonging to the FD's client (or held jointly with them), + * and holding enough available balance once holds, liens, clearing holds and its minimum balance are set + * aside. The KYC debit restriction on that account is checked by the callers through KycRestrictionGuard. + */ +@Component +@RequiredArgsConstructor +public class FixedDepositFundingValidator { + + /** m_savings_account.deposit_type_enum (Fineract DepositAccountType). */ + public static final int DEPOSIT_TYPE_SAVINGS = 100; + public static final int DEPOSIT_TYPE_FIXED = 200; + + private static final int STATUS_ACTIVE = 300; + private static final int TXN_TYPE_DEPOSIT = 1; + /** SavingsAccountSubStatusEnum.BLOCK (no debits or credits) and BLOCK_DEBIT. */ + private static final int SUB_STATUS_BLOCK = 400; + private static final int SUB_STATUS_BLOCK_DEBIT = 600; + + private final JdbcTemplate jdbcTemplate; + + /** Whether this m_savings_account id is a fixed deposit (a CURRENT account id must not be passed here). */ + public boolean isFixedDeposit(final Long savingsAccountId) { + if (savingsAccountId == null) { + return false; + } + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?", + Integer.class, savingsAccountId, DEPOSIT_TYPE_FIXED); + return count != null && count > 0; + } + + /** + * The FD's first and only funding: the account must be an active, not yet matured fixed deposit with no + * money in it, and the amount must be exactly its principal. + * + * @return the FD's client id, for the source-account checks + */ + public Long validateFirstFunding(final Long fdAccountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT s.account_no, s.status_enum, s.deposit_type_enum, s.client_id, s.account_balance_derived, + t.deposit_amount, t.maturity_date + FROM m_savings_account s + LEFT JOIN m_deposit_account_term_and_preclosure t ON t.savings_account_id = s.id + WHERE s.id = ? + """, fdAccountId); + if (rows.isEmpty()) { + throw fail("error.msg.fd.funding.account.not.found", "Fixed deposit account " + fdAccountId + " was not found."); + } + final Map fd = rows.get(0); + final String accountNo = (String) fd.get("account_no"); + + if (intOf(fd.get("deposit_type_enum")) != DEPOSIT_TYPE_FIXED) { + throw fail("error.msg.fd.funding.not.a.fixed.deposit", "Account " + accountNo + " is not a fixed deposit."); + } + if (intOf(fd.get("status_enum")) != STATUS_ACTIVE) { + throw fail("error.msg.fd.funding.not.active", + "Fixed deposit " + accountNo + " is not active. Only an active fixed deposit can be funded."); + } + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final LocalDate maturityDate = fd.get("maturity_date") instanceof java.sql.Date d ? d.toLocalDate() : null; + if (maturityDate != null && !businessDate.isBefore(maturityDate)) { + throw fail("error.msg.fd.funding.matured", + "Fixed deposit " + accountNo + " has reached its maturity date (" + maturityDate + ") and can no longer be funded."); + } + + final Integer deposits = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_type_enum = ?" + + " AND is_reversed = false", + Integer.class, fdAccountId, TXN_TYPE_DEPOSIT); + if ((deposits != null && deposits > 0) || amountOf(fd.get("account_balance_derived")).signum() > 0) { + throw fail("error.msg.fd.funding.already.funded", "Fixed deposit " + accountNo + + " is already funded. A fixed deposit is funded only once - open a new fixed deposit for more money."); + } + + final BigDecimal principal = fd.get("deposit_amount") == null ? null : amountOf(fd.get("deposit_amount")); + if (principal == null || principal.signum() <= 0) { + throw fail("error.msg.fd.funding.principal.missing", "Fixed deposit " + accountNo + " has no deposit amount (principal) set."); + } + if (amount == null || amount.compareTo(principal) != 0) { + throw fail("error.msg.fd.funding.amount.mismatch", "Amount " + (amount == null ? "0" : amount.toPlainString()) + + " for fixed deposit " + accountNo + " must be exactly its principal " + principal.toPlainString() + " - not less, not more."); + } + return fd.get("client_id") == null ? null : ((Number) fd.get("client_id")).longValue(); + } + + /** + * The Savings or Current account an FD is being funded from. + * + * @param fdAccountId + * the FD being funded + * @param fdClientId + * its client, from {@link #validateFirstFunding} + * @param sourceAccountId + * m_savings_account id, or m_current_account id when {@code sourceIsCurrent} + */ + public void validateFundingSource(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId, + final boolean sourceIsCurrent, final BigDecimal amount) { + final List> rows = sourceIsCurrent ? jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, sub_status_enum, client_id, account_balance_derived, on_hold_funds_derived, + total_savings_amount_on_hold, min_required_balance, enforce_min_required_balance, + allow_overdraft, overdraft_limit, lockedin_until_date_derived + FROM m_current_account WHERE id = ? + """, sourceAccountId) : jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, sub_status_enum, client_id, deposit_type_enum, account_balance_derived, + on_hold_funds_derived, total_savings_amount_on_hold, total_savings_amount_on_hold_clearing, + min_required_balance, enforce_min_required_balance, lockedin_until_date_derived + FROM m_savings_account WHERE id = ? + """, sourceAccountId); + if (rows.isEmpty()) { + throw fail("error.msg.fd.funding.source.not.found", "Source account " + sourceAccountId + " was not found."); + } + final Map src = rows.get(0); + final String accountNo = (String) src.get("account_no"); + + if (!sourceIsCurrent && intOf(src.get("deposit_type_enum")) != DEPOSIT_TYPE_SAVINGS) { + throw fail("error.msg.fd.funding.source.type", + "Account " + accountNo + " cannot fund a fixed deposit - only a Savings or Current account can."); + } + if (intOf(src.get("status_enum")) != STATUS_ACTIVE) { + throw fail("error.msg.fd.funding.source.not.active", "Source account " + accountNo + " is not active."); + } + final int subStatus = intOf(src.get("sub_status_enum")); + if (subStatus == SUB_STATUS_BLOCK || subStatus == SUB_STATUS_BLOCK_DEBIT) { + throw fail("error.msg.fd.funding.source.debit.frozen", "Source account " + accountNo + " is blocked for debits (debit freeze)."); + } + final LocalDate lockedUntil = src.get("lockedin_until_date_derived") instanceof java.sql.Date d ? d.toLocalDate() : null; + if (lockedUntil != null && DateUtils.getBusinessLocalDate().isBefore(lockedUntil)) { + throw fail("error.msg.fd.funding.source.locked.in", "Source account " + accountNo + " is locked-in until " + lockedUntil + "."); + } + + final Long sourceClientId = src.get("client_id") == null ? null : ((Number) src.get("client_id")).longValue(); + if (!sameClientOrJointHolder(fdAccountId, fdClientId, sourceAccountId, sourceIsCurrent, sourceClientId)) { + throw fail("error.msg.fd.funding.source.other.client", "Source account " + accountNo + + " does not belong to the fixed deposit's customer. Fund an FD only from the customer's own account" + + " or one they hold jointly."); + } + + BigDecimal available = amountOf(src.get("account_balance_derived")).subtract(amountOf(src.get("on_hold_funds_derived"))) + .subtract(amountOf(src.get("total_savings_amount_on_hold"))); + if (!sourceIsCurrent) { + available = available.subtract(amountOf(src.get("total_savings_amount_on_hold_clearing"))); + } + if (Boolean.TRUE.equals(src.get("enforce_min_required_balance"))) { + available = available.subtract(amountOf(src.get("min_required_balance"))); + } + if (sourceIsCurrent && Boolean.TRUE.equals(src.get("allow_overdraft"))) { + available = available.add(amountOf(src.get("overdraft_limit"))); + } + if (amount == null || available.compareTo(amount) < 0) { + throw fail("error.msg.fd.funding.source.insufficient", "Source account " + accountNo + " has an available balance of " + + available.max(BigDecimal.ZERO).toPlainString() + " (after holds, lien and minimum balance), less than " + + (amount == null ? "0" : amount.toPlainString()) + "."); + } + } + + private boolean sameClientOrJointHolder(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId, + final boolean sourceIsCurrent, final Long sourceClientId) { + if (fdClientId == null) { + return false; + } + if (Objects.equals(fdClientId, sourceClientId)) { + return true; + } + // The FD's customer is a joint holder on the source account ... + final Integer onSource = sourceIsCurrent + ? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_current_account_joint_holder WHERE current_account_id = ? AND client_id = ?", + Integer.class, sourceAccountId, fdClientId) + : jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class, + sourceAccountId, fdClientId); + if (onSource != null && onSource > 0) { + return true; + } + // ... or the source account's customer is a joint holder on the FD. + if (sourceClientId == null) { + return false; + } + final Integer onFd = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class, + fdAccountId, sourceClientId); + return onFd != null && onFd > 0; + } + + private static int intOf(final Object value) { + return value instanceof Number n ? n.intValue() : -1; + } + + private static BigDecimal amountOf(final Object value) { + return value instanceof BigDecimal b ? b : value instanceof Number n ? new BigDecimal(n.toString()) : BigDecimal.ZERO; + } + + private static GeneralPlatformDomainRuleException fail(final String code, final String message) { + return new GeneralPlatformDomainRuleException(code, message); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index afcf45061..f9639a0b7 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -39,6 +39,13 @@ public interface DepositAccountWritePlatformService { CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command); + /** + * Recomputes a fixed deposit's maturity date and amount from its real transaction history. Used after an FD + * is funded by a direct book credit (clerk Bank Transfer / Fund Transfer / Disbursement), which does not go + * through {@link #depositToFDAccount}. + */ + void recalculateFixedDepositMaturity(Long savingsId); + CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command); CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType);