diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java index 3b29b900c..aff6abadd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java @@ -35,6 +35,14 @@ public interface DepositAccountDomainService { SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate, BigDecimal transactionAmount, PaymentDetail paymentDetail); + /** + * As above, but lets the caller declare the deposit as an internal account-to-account movement rather + * than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight + * from the matured deposit and must post against the transfer-suspense GL instead of cash/vault. + */ + SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate, + BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer); + SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate, BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 2ae1124a7..05f48a177 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -86,7 +86,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt, final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, final boolean applyWithdrawFee, final boolean isRegularTransaction) { - boolean isAccountTransfer = false; + final boolean isAccountTransfer = false; + return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction, + isAccountTransfer); + } + + /** + * As above, but lets the caller declare the withdrawal as an internal account-to-account movement + * rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance + * moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault. + */ + private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt, + final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, + final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) { boolean isInterestTransfer = false; boolean isWithdrawBalance = false; final boolean backdatedTxnsAllowedTill = false; @@ -100,7 +112,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi @Override public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt, final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) { - boolean isAccountTransfer = false; + final boolean isAccountTransfer = false; + return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer); + } + + @Transactional + @Override + public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt, + final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, + final boolean isAccountTransfer) { boolean isRegularTransaction = false; final boolean backdatedTxnsAllowedTill = false; return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, @@ -207,8 +227,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); autoGenerateAccountNumber(reinvestedDeposit); + // Renewal is an internal movement into the new deposit, not cash paid out -- post it + // against the transfer-suspense GL rather than crediting cash/vault. + final boolean isInternalTransferLeg = true; final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), - paymentDetail, false, isRegularTransaction); + paymentDetail, false, isRegularTransaction, isInternalTransferLeg); savingsTransactionId = withdrawal.getId(); } else if (onClosureType.isTransferToSavings()) { final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); @@ -276,9 +299,17 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); autoGenerateAccountNumber(reinvestedDeposit); + // Renewal moves the balance internally into the new deposit -- no cash leaves the branch -- + // so this leg is posted as an account transfer (against the transfer-suspense GL) rather + // than as a counter withdrawal, which would wrongly credit the cash/vault account. + final boolean isInternalTransferLeg = true; final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction); + false, isRegularTransaction, isInternalTransferLeg); savingsTransactionId = withdrawal.getId(); + // handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as + // already accounted for -- exactly as the transfer-to-savings branch below does -- or the + // postJournalEntries() call at the end of this method posts it a second time. + updateAlreadyPostedTransactions(existingTransactionIds, account); if (onClosureType.isReinvestPrincipalAndInterest()) { account.updateClosedStatus(); @@ -304,6 +335,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); } // if(!processMaturityInstructionOnly) @@ -474,6 +506,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + // handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already + // accounted for or the postJournalEntries() call below posts it a second time. + updateAlreadyPostedTransactions(existingTransactionIds, account); } account.prematureClosure(user, command, changes); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 642fb8f08..95cff6e3f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -829,7 +829,12 @@ public class FixedDepositAccount extends SavingsAccount { final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType .fromInt(this.interestCalculationDaysInYearType); final BigDecimal minRequiredOpeningBalance = null; - final BigDecimal interestRate = BigDecimal.ZERO; + // Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses + // for a brand-new account -- so a product with no chart slab covering this tenure/amount + // doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still + // re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the + // original account was opened) whenever a matching slab exists. + final BigDecimal interestRate = product.nominalAnnualInterestRate(); final Set savingsAccountCharges = null; final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); final Integer lockinPeriodFrequency = this.lockinPeriodFrequency; @@ -845,6 +850,15 @@ public class FixedDepositAccount extends SavingsAccount { newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount); newChart.updateDepositAccountReference(reInvestedAccount); + // Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's + // reinvested FD silently drops back to the standard rate on the new term. + reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen()); + reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate()); + + // Track which account this was reinvested from, so staff/UI can trace the two accounts to + // each other instead of the link disappearing the moment the old one closes. + newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId()); + return reInvestedAccount; } @@ -886,7 +900,9 @@ public class FixedDepositAccount extends SavingsAccount { @Override public void loadLazyCollections() { super.loadLazyCollections(); - this.chart.getId(); + if (this.chart != null) { + this.chart.getId(); + } } public BigDecimal getDepositAmount() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 61d8afdf2..130b1fb60 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead ((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders); } } + if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) { + applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId); + } } return account; @@ -329,6 +332,43 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead } } + /** + * Populates the "reinvested from" / "reinvested into" links on a Fixed Deposit account, so the + * account-view page can show either direction of a "Re-Invest Maturity Amount"/"Re-Invest + * Principal Only" closure. There is only one column (reinvested_from_savings_id, set on the NEW + * account) -- "reinvested into" is found on the OLD account by reverse-looking up whichever row + * points at it. + */ + private void applyReinvestmentLinks(final org.apache.fineract.portfolio.savings.data.FixedDepositAccountData result, + final Long accountId) { + final String fromSql = "select src.id as id, src.account_no as accountNo " + + "from m_deposit_account_term_and_preclosure t " + + "join m_savings_account src on src.id = t.reinvested_from_savings_id " + "where t.savings_account_id = ?"; + final String intoSql = "select dest.id as id, dest.account_no as accountNo " + + "from m_deposit_account_term_and_preclosure t " + "join m_savings_account dest on dest.id = t.savings_account_id " + + "where t.reinvested_from_savings_id = ?"; + + Long fromId = null; + String fromNo = null; + final java.util.List> fromRows = this.jdbcTemplate.queryForList(fromSql, accountId); + if (!fromRows.isEmpty()) { + fromId = ((Number) fromRows.get(0).get("id")).longValue(); + fromNo = (String) fromRows.get(0).get("accountNo"); + } + + Long intoId = null; + String intoNo = null; + final java.util.List> intoRows = this.jdbcTemplate.queryForList(intoSql, accountId); + if (!intoRows.isEmpty()) { + intoId = ((Number) intoRows.get(0).get("id")).longValue(); + intoNo = (String) intoRows.get(0).get("accountNo"); + } + + if (fromId != null || intoId != null) { + result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo); + } + } + private java.util.List> retrieveJointHolders(final Long accountId) { final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, " + "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory " 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 79a9e8aa8..fe4c4a6c8 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 @@ -1404,16 +1404,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo if (changes.get("reinvestedDepositId") != null) { Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); - Money amountForDeposit = account.activateWithBalance(); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler .assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); + // activateWithBalance() always returns zero here -- it exists to stop a manually + // opened FD from auto-crediting itself at activation (funding is supposed to be a + // separate, clerk-posted deposit). A reinvestment isn't that: the balance was just + // withdrawn from the matured account and needs to land in the new one as part of + // this same automated closure, so it has to come from what reInvest() actually + // carried over onto the new account, not from that unrelated activation guard. + Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount()); Money activationChargeAmount = getActivationCharge(reinvestAccount); if (activationChargeAmount.isGreaterThanZero()) { payActivationCharge(reinvestAccount); amountForDeposit = amountForDeposit.plus(activationChargeAmount); } + // Matching internal-transfer leg for the withdrawal the closure just made on the + // matured account: the funds never touch cash, so this posts against the + // transfer-suspense GL instead of debiting cash/vault. + final boolean isInternalTransferLeg = true; this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(), - amountForDeposit.getAmount(), null); + amountForDeposit.getAmount(), null, isInternalTransferLeg); } } } else if (depositAccountType.isRecurringDeposit()) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d2407861e..22f8bf57a 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -537,4 +537,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_reinvested_from_to_deposit_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_reinvested_from_to_deposit_term.xml new file mode 100644 index 000000000..9e5c62f6c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_reinvested_from_to_deposit_term.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index 8bea46e3a..d67d12baf 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -138,6 +138,23 @@ public final class FixedDepositAccountData extends DepositAccountData { private Boolean isSeniorCitizen; private java.math.BigDecimal seniorCitizenInterestRate; + // Reinvestment trail: which account this one was reinvested FROM (set on the new account) and + // which account it was reinvested INTO (derived by reverse lookup on the old account), so staff + // can trace a "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure both directions + // instead of the link disappearing once the old account closes. + private Long reinvestedFromAccountId; + private String reinvestedFromAccountNo; + private Long reinvestedIntoAccountId; + private String reinvestedIntoAccountNo; + + public void setReinvestmentLinks(final Long reinvestedFromAccountId, final String reinvestedFromAccountNo, + final Long reinvestedIntoAccountId, final String reinvestedIntoAccountNo) { + this.reinvestedFromAccountId = reinvestedFromAccountId; + this.reinvestedFromAccountNo = reinvestedFromAccountNo; + this.reinvestedIntoAccountId = reinvestedIntoAccountId; + this.reinvestedIntoAccountNo = reinvestedIntoAccountNo; + } + // CBS account-level fields (stored per-account in m_savings_account) private String accountStatus; private String modeOfOperation; @@ -329,6 +346,10 @@ public final class FixedDepositAccountData extends DepositAccountData { this.jointHolders = src.jointHolders; this.isSeniorCitizen = src.isSeniorCitizen; this.seniorCitizenInterestRate = src.seniorCitizenInterestRate; + this.reinvestedFromAccountId = src.reinvestedFromAccountId; + this.reinvestedFromAccountNo = src.reinvestedFromAccountNo; + this.reinvestedIntoAccountId = src.reinvestedIntoAccountId; + this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo; this.subStatus = src.subStatus; this.reasonForBlock = src.reasonForBlock; this.lastActiveTransactionDate = src.lastActiveTransactionDate; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java index eb2fc6022..37b3db921 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java @@ -89,6 +89,21 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) { final List mappings = new java.util.ArrayList<>(); boolean any = false; any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, - "paymentTypeId", mappings); - any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", mappings); + "paymentTypeId", PAYMENT_CHANNEL_GL_FIELD, mappings); + any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", + INCOME_ACCOUNT_GL_FIELD, mappings); any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId", - mappings); + INCOME_ACCOUNT_GL_FIELD, mappings); return any ? mappings : null; } private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName, - final String mappingType, final String idFieldName, final List out) { + final String mappingType, final String idFieldName, final String glFieldName, + final List out) { if (!command.hasParameter(paramName)) { return false; } @@ -625,8 +635,13 @@ public final class ProductPendingEditFieldMapper { if (raw != null && raw.isJsonArray()) { for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) { final JsonObject o = el.getAsJsonObject(); - final Long idFieldValue = o.has(idFieldName) ? o.get(idFieldName).getAsLong() : null; - final Long glAccountId = o.has("glAccountId") ? o.get("glAccountId").getAsLong() : null; + final Long idFieldValue = longOrNull(o, idFieldName); + final Long glAccountId = longOrNull(o, glFieldName); + // A mapping is only meaningful with both halves present; keeping a half-filled row + // would fail the satellite table's NOT NULL constraint and abort the whole edit. + if (idFieldValue == null || glAccountId == null) { + continue; + } final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null; final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null; out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId)); @@ -635,15 +650,22 @@ public final class ProductPendingEditFieldMapper { return true; } + private static Long longOrNull(final JsonObject o, final String fieldName) { + final com.google.gson.JsonElement el = o.get(fieldName); + return el == null || el.isJsonNull() ? null : el.getAsLong(); + } + public static void writeGlArrayMappingsToJson(final JsonObject root, final List mappings) { writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings", - "paymentTypeId"); - writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId"); - writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId"); + "paymentTypeId", PAYMENT_CHANNEL_GL_FIELD); + writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId", + INCOME_ACCOUNT_GL_FIELD); + writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId", + INCOME_ACCOUNT_GL_FIELD); } private static void writeGlArrayGroup(final JsonObject root, final List mappings, final String type, - final String paramName, final String idFieldName) { + final String paramName, final String idFieldName, final String glFieldName) { final JsonArray array = new JsonArray(); for (final ProductPendingEditGlMapping mapping : mappings) { if (!type.equals(mapping.getMappingType())) { @@ -652,7 +674,7 @@ public final class ProductPendingEditFieldMapper { final JsonObject entry = new JsonObject(); final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId(); entry.addProperty(idFieldName, idFieldValue); - entry.addProperty("glAccountId", mapping.getGlAccountId()); + entry.addProperty(glFieldName, mapping.getGlAccountId()); array.add(entry); } root.add(paramName, array);