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. + * + * + * @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);