Merge branch 'dev-latest' into feature/bugfix

especially if it merges an updated upstream into a topic branch.
This commit is contained in:
2026-09-28 12:39:03 +05:30
14 changed files with 456 additions and 255 deletions
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
List<CurrentAccount> 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);
}
@@ -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);
@@ -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 ")
@@ -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
@@ -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));
}
}
}
@@ -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));
}
}
}
@@ -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<GlEntry> 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));
}
}
}
@@ -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);
}
@@ -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<String, Object> result = this.depositAccountWritePlatformService.previewFdRenewal(accountId);
return this.gson.toJson(result);
}
}
@@ -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).
* <p>
* 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()) {
@@ -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<String, Object> 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<String, Object> previewFdRenewal(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
final Map<String, Object> 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<Long> existingTransactionIds = new HashSet<>();
final Set<Long> 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;
}
@@ -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);
@@ -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<String, Object> 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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
@@ -105,14 +105,28 @@ public interface DepositAccountWritePlatformService {
Map<String, Object> 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<String, Object> 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<String, Object> previewFdRenewal(Long fdAccountId);
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
}