diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java index 4c5617f97..944afff22 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java @@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository findActiveByClientId(@Param("clientId") Long clientId); - @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600") + // statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior + // account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw() + // /.reject(), are equally terminal and were still being counted as "still has one", blocking a + // fresh application the client should have been free to make. 100/200/300 (submitted, approved, + // active) are the only statuses a second application on the same product should actually collide + // with. + @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)") long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index f8235a2a9..b5c375a61 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -651,6 +651,8 @@ public class AccountingProcessorHelper { final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); // The payable comes from the Interest Payable row on the product's Accounting tab, which is // where a bank actually picks it. The reserved name in the product's other GL mappings is @@ -664,13 +666,20 @@ public class AccountingProcessorHelper { final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) .orElse(payableAccount); - // The interest expense is not raised here. It is recognised as the interest accrues, day by - // day, and this posting settles that recognised debt - raising the expense again at payout - // would put the same interest on the P&L twice. This mirrors the pooled posting service, - // which pays a product's whole membership the same way; a closure reaching this path must - // produce the same entries as the nightly run, or the two disagree on what one payment did. + // Stage 1: the expense is recognised against whatever the liability sits in right now -- the + // named Payable account if the product has one, or straight against Savings Control if it + // doesn't. This was missing entirely: every product, configured or not, posted zero journal + // entries for interest until this leg existed, since a product with no Payable/Paid names + // configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else + // to post in their place. + postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // The interest expense is not raised again here. It was just raised above, and this posting + // only settles that recognised debt onward to where it is actually paid out from -- raising + // the expense a second time would put the same interest on the P&L twice. // - // Stage 1: the liability moves from the payable to the account it is paid out of. Where the + // Stage 2: the liability moves from the payable to the account it is paid out of. Where the // product names no paid account both resolve to the payable and this says nothing, so it is // skipped and the payout comes straight out of the payable. final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); @@ -679,7 +688,7 @@ public class AccountingProcessorHelper { isReversal); } - // Stage 2: into the member's own balance. + // Stage 3: into the member's own balance. final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java index 8115f66ea..7169ba0ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java @@ -105,17 +105,22 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .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, ") - // entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and - // current-account entries -- current products deliberately share SAVING's GL type - // space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two - // apart on its own, and m_savings_account/m_current_account each have their own id - // sequence: an entity_id can be a real row in both, making a plain - // COALESCE(ca.account_no, sa.account_no) pick the current account's number even for - // an entry that is genuinely a savings one. savings_transaction_id is the one - // unambiguous signal available here -- only a real savings transaction sets it -- so - // it decides first; only when it's absent (a current-account entry, or a savings - // entry with no linked transaction row) does the current-account guess apply. + // entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in + // m_current_account -- newer write paths tag current-account entries with it + // instead of reusing SAVING's type, specifically so this case needs no guessing. + // + // entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings + // and current-account entries by older write paths not yet updated to the above -- + // current products used to deliberately share SAVING's GL type space (see + // PortfolioProductType.CURRENT's own comment) -- and m_savings_account/ + // m_current_account each have their own id sequence, so an entity_id can be a real + // row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current + // account's number even for an entry that is genuinely a savings one. + // savings_transaction_id is the one unambiguous signal available for those older + // entries -- only a real savings transaction sets it -- so it decides first; only + // when it's absent does the current-account guess apply. .append(" CASE ") + .append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ") .append(" ELSE NULL ") @@ -141,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ") .append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ") .append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ") - .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 "); + .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) "); if (associationParametersData.isTransactionDetailsRequired()) { sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ") .append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java index db4ec204d..0e1e65ff3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java @@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ * The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL * posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable. */ + // m_savings_account and m_current_account each keep their own identity sequence, so the same + // entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to + // be written for both savings and current-account entries alike, so esa/eca could both match the + // same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever + // client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of + // misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT) + // is what newer write paths tag current-account entries with instead, unambiguously; for older + // entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine + // savings transaction sets it), checked before the ambiguous current/savings guess. private static final String RESOLVE_SQL = """ SELECT je.amount AS amount, je.currency_code AS currencyCode, @@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ je.loan_transaction_id AS loanTxnId, je.entity_type_enum AS entityType, COALESCE( - esa.client_id, - eca.client_id, + CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END, + CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END, + CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END, eloan.client_id, CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END, sa.client_id, @@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ ) AS clientId FROM acc_gl_journal_entry je LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2 - LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2 + LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6) LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1 LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index 66d0c6950..1954371b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; @@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService { } postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId, - accountId); + accountId, accountType); raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId); log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, chequeBookId); @@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService { /** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */ private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, - final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) { + final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId, + final ChequeAccountType accountType) { final JsonArray debitArr = new JsonArray(); final JsonObject debit = new JsonObject(); debit.addProperty("glAccountId", debitGlId); @@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService { // link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do. final String glTxnId = result.getTransactionId(); if (glTxnId != null) { - linkDebitEntryToAccount(glTxnId, accountId); + linkDebitEntryToAccount(glTxnId, accountId, accountType); } } - /** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */ - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an + * Account No. + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can + * be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of + * accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings + * account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually + * means "this id is in m_current_account" -- use it whenever accountType says so. + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = accountType == ChequeAccountType.CURRENT + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java index 9180b5bf6..535ce45c9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java @@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; @@ -118,7 +119,7 @@ public class ClearingJournalService { } final String glTxnId = result.getTransactionId(); if (glTxnId != null && linkAccountId != null) { - linkDebitEntryToAccount(glTxnId, linkAccountId); + linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType); } raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType); return glTxnId; @@ -191,7 +192,13 @@ public class ClearingJournalService { return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); } - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two + * account tables number independently, so entity_type_enum must say which one accountId names, not + * assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means + * "this id is in m_current_account". + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -200,8 +207,11 @@ public class ClearingJournalService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = "CURRENT".equalsIgnoreCase(accountType) + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } 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 b94837355..56f55e3ba 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 @@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +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.voucher.service.VoucherService; @@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource { ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); btTotalDr = btTotalDr.add(entryAmt); if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); @@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); } @@ -681,7 +682,7 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(directCaTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true)); undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId)); } else { // Real CBS book transfer: direct credit to savings account. @@ -696,7 +697,7 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(directTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false)); undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId)); } } else { @@ -715,7 +716,7 @@ public class ClerkDisbursementRequestApiResource { : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(debitTxnId)); withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); - collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount)); if (isCurrentAccount) { undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId)); } else { @@ -979,7 +980,7 @@ public class ClerkDisbursementRequestApiResource { : directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); } else { @@ -1005,7 +1006,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); } else { @@ -1568,20 +1569,27 @@ public class ClerkDisbursementRequestApiResource { final BigDecimal amount; final String accountRef; // savings account number shown in GL comments; null for source/target GL final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries - final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries + final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries + final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId) { - this(glAccountId, amount, accountRef, txnId, null); + this(glAccountId, amount, accountRef, txnId, null, false); } GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, final Long savingsAccountId) { + this(glAccountId, amount, accountRef, txnId, savingsAccountId, false); + } + + GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, + final Long savingsAccountId, final boolean isCurrentAccount) { this.glAccountId = glAccountId; this.amount = amount; this.accountRef = accountRef; this.txnId = txnId; this.savingsAccountId = savingsAccountId; + this.isCurrentAccount = isCurrentAccount; } } @@ -1603,8 +1611,16 @@ public class ClerkDisbursementRequestApiResource { /** * After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1) - * row to its savings account so the GL view shows ACCOUNT NO instead of "—". + * row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—". * Rows are matched to entries by insertion order (Fineract inserts in JSON array order). + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2 + * (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view + * would resolve a current account's entry to a same-numbered savings account or vice versa. + * entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's + * entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one + * this entry's id actually is, decided once already, where the account was first resolved. */ private void linkGlEntriesToSavingsAccounts(final String glTxnId, final List entries, final int typeEnum) { @@ -1618,11 +1634,15 @@ public class ClerkDisbursementRequestApiResource { Long.class, glTxnId, typeEnum, entries.size()); java.util.Collections.reverse(rowIds); for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) { - final Long savingsAccId = entries.get(i).savingsAccountId; + final GlEntry entry = entries.get(i); + final Long savingsAccId = entry.savingsAccountId; if (savingsAccId != null) { + final int entityType = entry.isCurrentAccount + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", - savingsAccId, rowIds.get(i)); + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", + entityType, savingsAccId, rowIds.get(i)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java index c1a1c8b51..9ab7d736f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java @@ -33,8 +33,12 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; /** - * FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder - * needs (account/client/product identity, maturity date, deposit and maturity amounts). + * FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the + * real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no + * closure instruction to auto-process -- with just the fields the maturity ladder needs + * (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays + * here until it's actually renewed or closed, since that's exactly the "needs manual attention" + * state the ladder exists to surface. * * Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that * endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency, @@ -57,6 +61,8 @@ public class FixedDepositMaturityDashboardApiResource { private static final int FIXED_DEPOSIT = 200; /** SavingsAccountStatusType.ACTIVE. */ private static final int ACTIVE = 300; + /** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */ + private static final int MATURED = 800; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; @@ -87,9 +93,9 @@ public class FixedDepositMaturityDashboardApiResource { JOIN m_savings_product sp ON sp.id = sa.product_id LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id LEFT JOIN m_client c ON c.id = sa.client_id - WHERE sa.deposit_type_enum = ? AND sa.status_enum = ? + WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) ORDER BY datp.maturity_date ASC - """, FIXED_DEPOSIT, ACTIVE); + """, FIXED_DEPOSIT, ACTIVE, MATURED); return this.gson.toJson(rows); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java new file mode 100644 index 000000000..7c3715caf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java @@ -0,0 +1,60 @@ +/** + * 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.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; + +/** + * Read-only: what a Renew from the FD Dashboard is about to do, before the staff member confirms + * anything. The Payment dialog calls this on open (dashboardRenewal mode) to show the current + * balance, the linked account's rate, the gap-day count, the computed gap-period interest, and the + * balance the new term will actually start from -- exactly the numbers + * {@code creditGapInterestIfPastGrace} would post, just not posted yet. Nothing here mutates the + * account, so this only needs read permission, unlike its posting sibling. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renewal-preview") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: read-only preview of a Renew action's gap-interest math.") +@RequiredArgsConstructor +public class FixedDepositRenewalPreviewApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("accountId") final Long accountId) { + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.previewFdRenewal(accountId); + return this.gson.toJson(result); + } +} 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 a884d9f19..acdd50fdc 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 @@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); account.setClosedOnDate(closedDate); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest()) { + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) { // Renews on the SAME account, so the customer keeps one account number however many times - // the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new - // term's principal, so there is no withdrawal, no deposit and no journal entry for the - // rollover itself -- which also removes the transfer-suspense round trip the old - // close-and-reopen flow needed. + // the deposit rolls over -- Reinvest Principal Only renews just the closing principal here + // (below), with only the leftover interest actually leaving, rather than the legacy + // close-and-reopen this branch used to use, which left the customer with a second account + // number for something that renews here just as cleanly as Reinvest Principal + Interest + // does. Full reinvest itself still moves nothing: the whole matured balance simply becomes + // the new term's principal, so there is no withdrawal, no deposit and no journal entry for + // the rollover itself. final BigDecimal closingPrincipal = account.getDepositAmount(); final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null : maturedBalance.subtract(closingPrincipal); + final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance; + // Whatever doesn't renew is interest the customer earned and has to actually be paid out + // somewhere -- validated before anything below runs, so a missing target fails cleanly with + // nothing done rather than leaving a half-completed reinvestment. + final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount); + if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { + throw new GeneralPlatformDomainRuleException( + "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", + "Reinvest Principal Only requires a Savings account to receive the leftover interest."); + } // The active CBS base interest-rate version for this FD type: the same rate card the Base // Interest Rates screen shows and the account-opening wizard prices from. Used for both the @@ -337,66 +350,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer closingTenure = account.depositPeriod(); final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); - account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); this.savingsAccountRepository.save(account); - recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate, + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate, closingTenure, closingTenureFrequencyEnum); changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - } else if (onClosureType.isReinvest()) { - // "Re-invest principal only" still closes and reopens, because only the principal carries - // forward and the interest has to leave the deposit -- which needs a payout destination that - // an in-place renewal has nowhere to send. Left on the original flow rather than guessing. - final BigDecimal maturedBalance = account.getAccountBalance(); - final BigDecimal reInvestAmount = account.getDepositAmount(); - // Whatever is left after the principal moves into the new deposit is interest the customer - // earned and has to actually be paid out somewhere -- it must not just sit on this account, - // which is about to be closed. Validated before anything below runs, so a missing target - // fails cleanly with nothing done rather than leaving a half-completed reinvestment. - final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount); - if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { - throw new GeneralPlatformDomainRuleException( - "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", - "Reinvest Principal Only requires a Savings account to receive the leftover interest."); - } - ExternalId externalId = this.externalIdFactory.create(); - - FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - 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, 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); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - // Reached from the maturity job processing the account's standing maturity instruction. - recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, - FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); - - // Pay out whatever didn't move into the new deposit, then actually close this account -- - // previously this branch left the leftover interest stranded on an account that was never - // closed, silently, because nothing past this point ever referenced toSavingsId at all. + // Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is + // zero (renewedAmount already equals maturedBalance), so this stays a no-op for it. if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); @@ -409,8 +376,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); } - account.updateClosedStatus(); - account.updateOnAccountClosureStatus(onClosureType); } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, @@ -751,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance, closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure, closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById)); + // Only on this first-ever close of this term -- otherwise a term that renews more than once + // (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the + // same chart against the same closingRow every time, piling up duplicate slab rows for a + // chart that only ever needs archiving once. + archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); } closingRow.recordOutcome(closingInterestEarned, maturedBalance); closingRow.markMatured(); this.fixedDepositRenewalHistoryRepository.save(closingRow); - archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); - // The new term's own interest/maturity stay unset until it closes in turn. this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(), account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount, @@ -884,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi * A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close * (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support * -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest - * transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through - * {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what - * the existing manual Close command still uses today. + * transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and + * Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just + * Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with + * only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest} + * this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's + * own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an + * already-stored instruction). *

* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} -- * neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as @@ -953,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) { - // These two branches keep the account open, so they never reach close()/prematureClosure() and - // would otherwise accept any date at all -- including one behind already-posted interest, which - // the renewal would then turn into a pivot that reverses and re-posts it. + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { + // These three branches keep the account open, so they never reach close()/prematureClosure() + // and would otherwise accept any date at all -- including one behind already-posted interest, + // which the renewal would then turn into a pivot that reverses and re-posts it. account.validateRenewalDate(closedDate); final BigDecimal closingPrincipal = account.getDepositAmount(); @@ -972,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid", "The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount); } + } else if (onClosureType.isReinvestPrincipal()) { + // Exactly the closing term's own principal -- the fixed point on the same split Partial + // Reinvest lets staff pick freely. Renews in place same as the other two, on just this + // account, with only the interest actually leaving (below) -- no second account, unlike the + // legacy close-and-reopen this branch used to do. + renewedAmount = closingPrincipal; } else { renewedAmount = maturedBalance; } @@ -1004,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - if (onClosureType.isPartialReinvest()) { + if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { // The kept portion never left the account -- renewInPlace() just relabels it as the new - // term's principal -- so only the remainder is an actual money movement, posted the same - // way the Transfer to Savings branch below posts its full-balance transfer. + // term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is + // an actual money movement, posted the same way the Transfer to Savings branch below posts + // its full-balance transfer. final BigDecimal remainder = maturedBalance.subtract(renewedAmount); - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS, - PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null, - AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, - isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - } else if (onClosureType.isReinvestPrincipal()) { - // Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch -- - // the interest has to leave the deposit, which needs a payout destination an in-place renewal - // has nowhere to send. - final BigDecimal maturedBalance = account.getAccountBalance(); - final BigDecimal reInvestAmount = account.getDepositAmount(); - final ExternalId externalId = this.externalIdFactory.create(); - - final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); - autoGenerateAccountNumber(reinvestedDeposit); - final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction, isInternalTransferLeg); - savingsTransactionId = withdrawal.getId(); - updateAlreadyPostedTransactions(existingTransactionIds, account); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, - FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); - - // Only the principal moved into the new deposit -- the interest earned on this term is still - // sitting in this account's balance. Pay it out to the chosen savings account now, before - // closing, rather than stranding it here with nowhere left to go. - final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount); - if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) { - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder, - PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, - null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, - toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - if (alreadyMatured) { - account.close(user, command, changes); - } else { - account.prematureClosure(user, command, changes); + if (remainder.compareTo(BigDecimal.ZERO) > 0) { + final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); + final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, + null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, + toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } } } else if (onClosureType.isTransferToSavings()) { 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 4484134c7..42e258e8e 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 @@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes); - // Reinvest Principal Only opens a second account and the domain method withdrew the principal out - // of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually - // land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path. + // Reinvest Principal Only now renews in place on this same account (see + // DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually + // set any more, but this guard is left as a harmless no-op rather than removed, in case a future + // closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler @@ -1554,7 +1555,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // an account opened straight into Reinvest Principal Only can reach here with none set. // Falls back to the FD's own Deposit Account Link rather than failing the whole job run. if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { - toSavingsId = fdAccount.getLinkedAccountId(); + toSavingsId = resolveLinkedAccountId(fdAccount.getId()); } this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); @@ -1566,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // would treat it as new and post it a second time without this. markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); + // Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, + // in case a future closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); 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()) { @@ -1620,7 +1618,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // Only Principal pays the leftover interest out to the FD's own linked Savings account -- the // account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this // should always be present, but fail clearly rather than silently stranding the interest if not. - final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId(); + final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId()); if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) { throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", "This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, " @@ -1630,13 +1628,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // Renewal grace period only shrinks the backdate window below -- it is no longer a precondition // for whether Renew is allowed at all. A product that never configured one behaves as a 0-day // window: renewing on the maturity date itself backdates, any later renewal pays gap interest. - Integer renewalGracePeriodDays = null; - if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { - renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); - } - if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { - renewalGracePeriodDays = 0; - } + final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); final LocalDate maturityDate = fdAccount.maturityDate(); final LocalDate today = DateUtils.getBusinessLocalDate(); @@ -1669,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); - // REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId); - // REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that - // actually carries the new term's maturity date/amount either way. + // Both reinvest types now renew this same account in place (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, in case + // a future closure type reintroduces a genuine close-and-reopen. FixedDepositAccount renewedAccount = fdAccount; if (changes.get("reinvestedDepositId") != null) { renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"), @@ -1697,17 +1690,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo "This FD has not matured yet."); } - Integer renewalGracePeriodDays = null; - if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { - renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); - } - if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { - renewalGracePeriodDays = 0; - } - final LocalDate maturityDate = fdAccount.maturityDate(); final LocalDate today = DateUtils.getBusinessLocalDate(); - final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount))); final Map result = new HashMap<>(); result.put("withinGrace", withinGrace); @@ -1716,49 +1701,160 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo } /** - * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its - * linked Savings/Current account's product -- credited onto the FD itself as a genuine - * INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the - * cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD - * Dashboard's generic Payment command) reads the account's balance. Shared by - * {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's - * Renew action now calls the latter as a pre-step before submitting the same generic `payment` - * command the account's own Account Actions menu already uses, so every closure type (Transfer to - * Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the - * two this method's sibling endpoint originally supported. + * Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment + * dialog before the staff member confirms anything -- the actual current balance, the linked + * account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid + * onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so + * projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews. + * Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets + * zero for the gap fields since nothing is owed. */ - private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { - final Long linkedAccountId = fdAccount.getLinkedAccountId(); - if (linkedAccountId == null) { - throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", - "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it " - + "before renewing late, so the gap-period interest has a rate to use."); + @Transactional(readOnly = true) + @Override + public Map previewFdRenewal(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + final Map result = new HashMap<>(); + if (!fdAccount.isMatured()) { + result.put("isMatured", false); + return result; } - final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject(""" + result.put("isMatured", true); + + final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + final BigDecimal currentBalance = fdAccount.getAccountBalance(); + + result.put("maturityDate", maturityDate.toString()); + result.put("renewalGracePeriodDays", renewalGracePeriodDays); + result.put("withinGrace", withinGrace); + result.put("currentBalance", currentBalance); + + if (withinGrace) { + result.put("gapDays", 0L); + result.put("gapInterestAmount", BigDecimal.ZERO); + result.put("projectedBalance", currentBalance); + return result; + } + + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + result.put("gapDays", gapDays); + + final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); + result.put("linkedAccountId", linkedAccountId); + if (linkedAccountId == null) { + // No target to read a rate from -- the caller (the Payment dialog) shows this as a blocking + // "link a Savings account first" message rather than a number, same as the posting path does. + result.put("gapInterestAmount", null); + result.put("projectedBalance", null); + return result; + } + result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId)); + + final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); + final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); + result.put("linkedAccountRate", linkedAccountRate); + result.put("gapInterestAmount", gapInterestAmount); + // Paid onto the linked savings account, not this FD -- what renews is still just the FD's own + // current balance. + result.put("projectedBalance", currentBalance); + return result; + } + + /** Null/negative reads as "no grace configured" -- a 0-day window, not a block on renewing. */ + private int resolveRenewalGracePeriodDays(final FixedDepositAccount fdAccount) { + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + return (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) ? 0 : renewalGracePeriodDays; + } + + /** + * The FD's own Deposit Account Link, read fresh via SQL rather than the JPA entity's + * {@code getLinkedAccountId()} -- an account whose link was set at creation through the real + * account-opening command reads back correctly there, but this call site kept returning null for + * an account confirmed to have {@code linked_account_id} set in the database, which every one of + * this method's own gap-interest/reinvest-target callers depends on. Reading it directly sidesteps + * whatever staleness that entity accessor has, rather than trying to chase it down across every + * caller that touches this account within the same request. + */ + private Long resolveLinkedAccountId(final Long savingsAccountId) { + return this.jdbcTemplate.queryForObject("SELECT linked_account_id FROM m_savings_account WHERE id = ?", Long.class, + savingsAccountId); + } + + private BigDecimal resolveLinkedAccountRate(final Long linkedAccountId) { + return this.jdbcTemplate.queryForObject(""" SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa JOIN m_savings_product sp ON sp.id = sa.product_id WHERE sa.id = ? """, BigDecimal.class, linkedAccountId); + } - final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + /** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} -- + * shown in the Renewal Preview so staff can see exactly which account the gap interest is about + * to land on, not just that "a linked account" exists. */ + private String resolveLinkedAccountNo(final Long linkedAccountId) { + return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId); + } + + /** Simple interest on the FD's own current balance, for the stretch between its maturity date and + * today, at the linked account's product rate -- the pure calculation, shared by the posting path + * ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number + * shown to staff before they confirm is exactly the number that gets credited. */ + private BigDecimal computeGapInterestAmount(final FixedDepositAccount fdAccount, final BigDecimal linkedAccountRate, + final long gapDays) { final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 : fdAccount.getInterestCalculationDaysInYearType(); final MathContext mc = MoneyHelper.getMathContext(); - final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc) - .multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc) - .setScale(2, java.math.RoundingMode.HALF_UP); + return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc) + .divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP); + } + + /** + * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its + * linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine + * INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in + * the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at + * the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding + * it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings + * interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay + * it out through the FD's own closure math instead of it already being where it belongs. The FD's + * own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or + * the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap + * interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit} + * and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as + * a pre-step before submitting the same generic `payment` command the account's own Account Actions + * menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial + * Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint + * originally supported. + */ + private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { + final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); + if (linkedAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it " + + "before renewing late, so the gap-period interest has somewhere to be paid."); + } + final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT); + final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { final Set existingTransactionIds = new HashSet<>(); final Set existingReversedTransactionIds = new HashSet<>(); - updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds); - final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(), - today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true); - fdAccount.addTransaction(gapInterestTxn); - fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions()); - this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); - postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount, + linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true); + linkedAccount.addTransaction(gapInterestTxn); + linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions()); + this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount); + postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds); } return gapInterestAmount; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java index 301e3bbcf..053998bf9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java @@ -402,8 +402,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements msg = "Savings product with short name `" + shortName + "` already exists"; param = "shortName"; msgArgs = new Object[] { shortName, dae }; + } else if (checkEx.getMessage().contains("numeric field overflow")) { + msgCode += ".amount.too.large"; + msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again."; + msgArgs = new Object[] { dae }; } else { msgCode += ".unknown.data.integrity.issue"; + msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage(); msgArgs = new Object[] { dae }; } log.error("Error occured.", dae); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index d263de1bc..54471b2db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine; @@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null; final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null; @@ -627,6 +629,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); @@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD issuance - DD No " + ddNumber; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, - chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft( leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, @@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI Long resultingCurrentTransactionId = null; final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; // Book debit, no journal entry of its own -- see approveIssueRequest(). @@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException( @@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges, - chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); for (final Map request : requests) { this.sponsorDDWritePlatformService.issueDemandDraft( @@ -1757,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI * liability, so they get their own credit leg rather than being folded into the liability). Resolves * the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request / * m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's - * savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 / - * entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching - * how Fineract's own auto-generated entries display. + * savings/current account for an account-debit DD), the Dr-side row is tagged + * entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so + * GL reports show the account number instead of a blank "—", matching how Fineract's own + * auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account + * each with their own sequence) can't resolve a same-numbered stranger's account instead. */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId, - txnDate, comments, linkedAccountId); + txnDate, comments, linkedAccountId, false); } /** @@ -1811,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, - final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { + final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId, + final boolean linkedAccountIsCurrent) { final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0; @@ -1872,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever // being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly. if (linkedAccountId != null) { + // m_current_account and m_savings_account each keep their own identity sequence, so the same + // numeric id can be a real row in both -- entity_type_enum must say which one linkedAccountId + // names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit + // branch resolves either a savingsAccountId or a currentAccountId into this same parameter). + final int entityType = linkedAccountIsCurrent + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = (" + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = (" + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + " ORDER BY id DESC LIMIT 1)", - linkedAccountId, transactionId); + entityType, linkedAccountId, transactionId); } final List> rows = this.jdbcTemplate.queryForList( 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 11b836c37..3c18db4df 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 @@ -105,14 +105,28 @@ public interface DepositAccountWritePlatformService { Map renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType); /** - * Credits gap-period interest (simple interest, at the linked Savings/Current account's product - * rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning - * zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the - * generic `payment` command (the same one the account's own Account Actions menu uses), so every - * closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit} - * itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). + * Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto + * that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has + * passed; a no-op, returning zero, if still within it. Paid to the linked account because it was + * earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it + * renews from its real maturity amount regardless of which closure type follows. Called as a + * pre-step before the FD Dashboard's Renew action submits the generic `payment` command (the same + * one the account's own Account Actions menu uses), so every closure type is available from the + * Dashboard, not just the two {@link #renewMaturedFixedDeposit} itself supports. Returns + * {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). */ Map creditGapInterestIfPastGrace(Long fdAccountId); + /** + * Read-only preview of what renewing this matured FD right now would do -- shown in the FD + * Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns + * isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace, + * currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo, + * linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and + * projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually + * renews). + */ + Map previewFdRenewal(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); }