fix(deposits): correct FD reinvestment on maturity and unblock product edit submission
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Reinvestment on maturity was creating the new deposit with a zero balance, because it sourced the amount from activateWithBalance() -- a guard that deliberately returns zero to stop a manually opened FD from auto-crediting itself. It now carries over what reInvest() actually put on the new account. Both legs of the renewal are also posted as an internal transfer, so the funds move through the transfer-suspense GL instead of appearing to leave and re-enter the cash vault. Reinvested accounts no longer fall back to a 0% rate when no chart slab covers the tenure, and a senior-citizen depositor keeps their premium rate on the renewed term. Adds a reinvestment trail: the new account records which account it came from, and the account view resolves both directions of the link so either side can be traced. Also fixes product edit submission, which failed outright for any product carrying payment-channel GL mappings: the pending-edit mapper read "glAccountId" while the payload carries "fundSourceAccountId" (and "incomeAccountId" for fee/penalty arrays), leaving gl_account_id null and tripping the satellite table's NOT NULL constraint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+8
@@ -35,6 +35,14 @@ public interface DepositAccountDomainService {
|
|||||||
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
||||||
BigDecimal transactionAmount, PaymentDetail paymentDetail);
|
BigDecimal transactionAmount, PaymentDetail paymentDetail);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but lets the caller declare the deposit as an internal account-to-account movement rather
|
||||||
|
* than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight
|
||||||
|
* from the matured deposit and must post against the transfer-suspense GL instead of cash/vault.
|
||||||
|
*/
|
||||||
|
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
||||||
|
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer);
|
||||||
|
|
||||||
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
||||||
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
|
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
|
||||||
|
|
||||||
|
|||||||
+39
-4
@@ -86,7 +86,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
|
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
|
||||||
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
||||||
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
|
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
|
||||||
boolean isAccountTransfer = false;
|
final boolean isAccountTransfer = false;
|
||||||
|
return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction,
|
||||||
|
isAccountTransfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but lets the caller declare the withdrawal as an internal account-to-account movement
|
||||||
|
* rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance
|
||||||
|
* moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault.
|
||||||
|
*/
|
||||||
|
private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
|
||||||
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
||||||
|
final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) {
|
||||||
boolean isInterestTransfer = false;
|
boolean isInterestTransfer = false;
|
||||||
boolean isWithdrawBalance = false;
|
boolean isWithdrawBalance = false;
|
||||||
final boolean backdatedTxnsAllowedTill = false;
|
final boolean backdatedTxnsAllowedTill = false;
|
||||||
@@ -100,7 +112,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
@Override
|
@Override
|
||||||
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
|
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
|
||||||
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
|
||||||
boolean isAccountTransfer = false;
|
final boolean isAccountTransfer = false;
|
||||||
|
return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
|
||||||
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
||||||
|
final boolean isAccountTransfer) {
|
||||||
boolean isRegularTransaction = false;
|
boolean isRegularTransaction = false;
|
||||||
final boolean backdatedTxnsAllowedTill = false;
|
final boolean backdatedTxnsAllowedTill = false;
|
||||||
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
|
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
|
||||||
@@ -207,8 +227,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
||||||
autoGenerateAccountNumber(reinvestedDeposit);
|
autoGenerateAccountNumber(reinvestedDeposit);
|
||||||
|
// Renewal is an internal movement into the new deposit, not cash paid out -- post it
|
||||||
|
// against the transfer-suspense GL rather than crediting cash/vault.
|
||||||
|
final boolean isInternalTransferLeg = true;
|
||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||||
paymentDetail, false, isRegularTransaction);
|
paymentDetail, false, isRegularTransaction, isInternalTransferLeg);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
} else if (onClosureType.isTransferToSavings()) {
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||||
@@ -276,9 +299,17 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
|
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
||||||
autoGenerateAccountNumber(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,
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
||||||
false, isRegularTransaction);
|
false, isRegularTransaction, isInternalTransferLeg);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
|
||||||
|
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
|
||||||
|
// postJournalEntries() call at the end of this method posts it a second time.
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
|
||||||
if (onClosureType.isReinvestPrincipalAndInterest()) {
|
if (onClosureType.isReinvestPrincipalAndInterest()) {
|
||||||
account.updateClosedStatus();
|
account.updateClosedStatus();
|
||||||
@@ -304,6 +335,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||||
paymentDetail, false, isRegularTransaction);
|
paymentDetail, false, isRegularTransaction);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if(!processMaturityInstructionOnly)
|
// if(!processMaturityInstructionOnly)
|
||||||
@@ -474,6 +506,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||||
paymentDetail, false, isRegularTransaction);
|
paymentDetail, false, isRegularTransaction);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
|
||||||
|
// accounted for or the postJournalEntries() call below posts it a second time.
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
account.prematureClosure(user, command, changes);
|
account.prematureClosure(user, command, changes);
|
||||||
|
|||||||
+18
-2
@@ -829,7 +829,12 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
|
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
|
||||||
.fromInt(this.interestCalculationDaysInYearType);
|
.fromInt(this.interestCalculationDaysInYearType);
|
||||||
final BigDecimal minRequiredOpeningBalance = null;
|
final BigDecimal minRequiredOpeningBalance = null;
|
||||||
final BigDecimal interestRate = BigDecimal.ZERO;
|
// Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses
|
||||||
|
// for a brand-new account -- so a product with no chart slab covering this tenure/amount
|
||||||
|
// doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still
|
||||||
|
// re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the
|
||||||
|
// original account was opened) whenever a matching slab exists.
|
||||||
|
final BigDecimal interestRate = product.nominalAnnualInterestRate();
|
||||||
final Set<SavingsAccountCharge> savingsAccountCharges = null;
|
final Set<SavingsAccountCharge> savingsAccountCharges = null;
|
||||||
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
|
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
|
||||||
final Integer lockinPeriodFrequency = this.lockinPeriodFrequency;
|
final Integer lockinPeriodFrequency = this.lockinPeriodFrequency;
|
||||||
@@ -845,6 +850,15 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
|
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
|
||||||
newChart.updateDepositAccountReference(reInvestedAccount);
|
newChart.updateDepositAccountReference(reInvestedAccount);
|
||||||
|
|
||||||
|
// Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's
|
||||||
|
// reinvested FD silently drops back to the standard rate on the new term.
|
||||||
|
reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen());
|
||||||
|
reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate());
|
||||||
|
|
||||||
|
// Track which account this was reinvested from, so staff/UI can trace the two accounts to
|
||||||
|
// each other instead of the link disappearing the moment the old one closes.
|
||||||
|
newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId());
|
||||||
|
|
||||||
return reInvestedAccount;
|
return reInvestedAccount;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -886,7 +900,9 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
@Override
|
@Override
|
||||||
public void loadLazyCollections() {
|
public void loadLazyCollections() {
|
||||||
super.loadLazyCollections();
|
super.loadLazyCollections();
|
||||||
this.chart.getId();
|
if (this.chart != null) {
|
||||||
|
this.chart.getId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public BigDecimal getDepositAmount() {
|
public BigDecimal getDepositAmount() {
|
||||||
|
|||||||
+40
@@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
|
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) {
|
||||||
|
applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return account;
|
return account;
|
||||||
|
|
||||||
@@ -329,6 +332,43 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates the "reinvested from" / "reinvested into" links on a Fixed Deposit account, so the
|
||||||
|
* account-view page can show either direction of a "Re-Invest Maturity Amount"/"Re-Invest
|
||||||
|
* Principal Only" closure. There is only one column (reinvested_from_savings_id, set on the NEW
|
||||||
|
* account) -- "reinvested into" is found on the OLD account by reverse-looking up whichever row
|
||||||
|
* points at it.
|
||||||
|
*/
|
||||||
|
private void applyReinvestmentLinks(final org.apache.fineract.portfolio.savings.data.FixedDepositAccountData result,
|
||||||
|
final Long accountId) {
|
||||||
|
final String fromSql = "select src.id as id, src.account_no as accountNo "
|
||||||
|
+ "from m_deposit_account_term_and_preclosure t "
|
||||||
|
+ "join m_savings_account src on src.id = t.reinvested_from_savings_id " + "where t.savings_account_id = ?";
|
||||||
|
final String intoSql = "select dest.id as id, dest.account_no as accountNo "
|
||||||
|
+ "from m_deposit_account_term_and_preclosure t " + "join m_savings_account dest on dest.id = t.savings_account_id "
|
||||||
|
+ "where t.reinvested_from_savings_id = ?";
|
||||||
|
|
||||||
|
Long fromId = null;
|
||||||
|
String fromNo = null;
|
||||||
|
final java.util.List<java.util.Map<String, Object>> fromRows = this.jdbcTemplate.queryForList(fromSql, accountId);
|
||||||
|
if (!fromRows.isEmpty()) {
|
||||||
|
fromId = ((Number) fromRows.get(0).get("id")).longValue();
|
||||||
|
fromNo = (String) fromRows.get(0).get("accountNo");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long intoId = null;
|
||||||
|
String intoNo = null;
|
||||||
|
final java.util.List<java.util.Map<String, Object>> intoRows = this.jdbcTemplate.queryForList(intoSql, accountId);
|
||||||
|
if (!intoRows.isEmpty()) {
|
||||||
|
intoId = ((Number) intoRows.get(0).get("id")).longValue();
|
||||||
|
intoNo = (String) intoRows.get(0).get("accountNo");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromId != null || intoId != null) {
|
||||||
|
result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
|
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
|
||||||
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
|
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
|
||||||
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
|
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
|
||||||
|
|||||||
+12
-2
@@ -1404,16 +1404,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
|
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
||||||
Money amountForDeposit = account.activateWithBalance();
|
|
||||||
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
||||||
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
.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);
|
Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
||||||
if (activationChargeAmount.isGreaterThanZero()) {
|
if (activationChargeAmount.isGreaterThanZero()) {
|
||||||
payActivationCharge(reinvestAccount);
|
payActivationCharge(reinvestAccount);
|
||||||
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
|
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
|
||||||
}
|
}
|
||||||
|
// Matching internal-transfer leg for the withdrawal the closure just made on the
|
||||||
|
// matured account: the funds never touch cash, so this posts against the
|
||||||
|
// transfer-suspense GL instead of debiting cash/vault.
|
||||||
|
final boolean isInternalTransferLeg = true;
|
||||||
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
|
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
|
||||||
amountForDeposit.getAmount(), null);
|
amountForDeposit.getAmount(), null, isInternalTransferLeg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (depositAccountType.isRecurringDeposit()) {
|
} else if (depositAccountType.isRecurringDeposit()) {
|
||||||
|
|||||||
@@ -537,4 +537,5 @@
|
|||||||
<include file="parts/0467_add_welcome_kit_return_tracking.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0467_add_welcome_kit_return_tracking.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0468_add_address_id_to_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0468_add_address_id_to_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0469_create_database_export_request.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0469_create_database_export_request.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0470_add_reinvested_from_to_deposit_term.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- On "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure, a brand new FD/RD account is
|
||||||
|
auto-created with no visible link back to the matured account it came from, so staff had no way
|
||||||
|
to trace one from the other. This column, set on the NEW account, points at the OLD one; the OLD
|
||||||
|
account's own "reinvested into" side is derived by looking up whichever row has this account's id
|
||||||
|
here, so only one column is needed to track the relationship in both directions. -->
|
||||||
|
<changeSet id="0470_add_reinvested_from_to_deposit_term" author="CBS" runOnChange="false">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="reinvested_from_savings_id"/>
|
||||||
|
</not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_deposit_account_term_and_preclosure">
|
||||||
|
<column name="reinvested_from_savings_id" type="BIGINT"/>
|
||||||
|
</addColumn>
|
||||||
|
<createIndex indexName="reinvested_from_savings_id" tableName="m_deposit_account_term_and_preclosure">
|
||||||
|
<column name="reinvested_from_savings_id"/>
|
||||||
|
</createIndex>
|
||||||
|
<addForeignKeyConstraint baseColumnNames="reinvested_from_savings_id"
|
||||||
|
baseTableName="m_deposit_account_term_and_preclosure"
|
||||||
|
constraintName="m_deposit_account_term_and_preclosure_reinvested_from_ibfk"
|
||||||
|
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
|
||||||
|
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+21
@@ -138,6 +138,23 @@ public final class FixedDepositAccountData extends DepositAccountData {
|
|||||||
private Boolean isSeniorCitizen;
|
private Boolean isSeniorCitizen;
|
||||||
private java.math.BigDecimal seniorCitizenInterestRate;
|
private java.math.BigDecimal seniorCitizenInterestRate;
|
||||||
|
|
||||||
|
// Reinvestment trail: which account this one was reinvested FROM (set on the new account) and
|
||||||
|
// which account it was reinvested INTO (derived by reverse lookup on the old account), so staff
|
||||||
|
// can trace a "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure both directions
|
||||||
|
// instead of the link disappearing once the old account closes.
|
||||||
|
private Long reinvestedFromAccountId;
|
||||||
|
private String reinvestedFromAccountNo;
|
||||||
|
private Long reinvestedIntoAccountId;
|
||||||
|
private String reinvestedIntoAccountNo;
|
||||||
|
|
||||||
|
public void setReinvestmentLinks(final Long reinvestedFromAccountId, final String reinvestedFromAccountNo,
|
||||||
|
final Long reinvestedIntoAccountId, final String reinvestedIntoAccountNo) {
|
||||||
|
this.reinvestedFromAccountId = reinvestedFromAccountId;
|
||||||
|
this.reinvestedFromAccountNo = reinvestedFromAccountNo;
|
||||||
|
this.reinvestedIntoAccountId = reinvestedIntoAccountId;
|
||||||
|
this.reinvestedIntoAccountNo = reinvestedIntoAccountNo;
|
||||||
|
}
|
||||||
|
|
||||||
// CBS account-level fields (stored per-account in m_savings_account)
|
// CBS account-level fields (stored per-account in m_savings_account)
|
||||||
private String accountStatus;
|
private String accountStatus;
|
||||||
private String modeOfOperation;
|
private String modeOfOperation;
|
||||||
@@ -329,6 +346,10 @@ public final class FixedDepositAccountData extends DepositAccountData {
|
|||||||
this.jointHolders = src.jointHolders;
|
this.jointHolders = src.jointHolders;
|
||||||
this.isSeniorCitizen = src.isSeniorCitizen;
|
this.isSeniorCitizen = src.isSeniorCitizen;
|
||||||
this.seniorCitizenInterestRate = src.seniorCitizenInterestRate;
|
this.seniorCitizenInterestRate = src.seniorCitizenInterestRate;
|
||||||
|
this.reinvestedFromAccountId = src.reinvestedFromAccountId;
|
||||||
|
this.reinvestedFromAccountNo = src.reinvestedFromAccountNo;
|
||||||
|
this.reinvestedIntoAccountId = src.reinvestedIntoAccountId;
|
||||||
|
this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo;
|
||||||
this.subStatus = src.subStatus;
|
this.subStatus = src.subStatus;
|
||||||
this.reasonForBlock = src.reasonForBlock;
|
this.reasonForBlock = src.reasonForBlock;
|
||||||
this.lastActiveTransactionDate = src.lastActiveTransactionDate;
|
this.lastActiveTransactionDate = src.lastActiveTransactionDate;
|
||||||
|
|||||||
+15
@@ -89,6 +89,21 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
|
|||||||
@Column(name = "transfer_to_savings_account_id")
|
@Column(name = "transfer_to_savings_account_id")
|
||||||
private Long transferToSavingsAccountId;
|
private Long transferToSavingsAccountId;
|
||||||
|
|
||||||
|
// Set on the NEW account created by a "Re-Invest" maturity closure, pointing back at the account
|
||||||
|
// it was reinvested from. There is no separate "reinvested into" column on the OLD account -- that
|
||||||
|
// direction is derived by looking up whichever row has this account's id here (see
|
||||||
|
// DepositAccountReadPlatformServiceImpl), so the relationship needs tracking in only one place.
|
||||||
|
@Column(name = "reinvested_from_savings_id")
|
||||||
|
private Long reinvestedFromSavingsId;
|
||||||
|
|
||||||
|
public Long getReinvestedFromSavingsId() {
|
||||||
|
return this.reinvestedFromSavingsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReinvestedFromSavingsId(final Long reinvestedFromSavingsId) {
|
||||||
|
this.reinvestedFromSavingsId = reinvestedFromSavingsId;
|
||||||
|
}
|
||||||
|
|
||||||
protected DepositAccountTermAndPreClosure() {
|
protected DepositAccountTermAndPreClosure() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-11
@@ -605,19 +605,29 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
|
|
||||||
// ---- GL array mappings (1:many, satellite table) -----------------------------------------------
|
// ---- GL array mappings (1:many, satellite table) -----------------------------------------------
|
||||||
|
|
||||||
|
// The GL-account field is named differently per mapping array in the product payload -- payment
|
||||||
|
// channels carry "fundSourceAccountId", fee/penalty arrays carry "incomeAccountId" (see
|
||||||
|
// AccountingConstants.LoanProductAccountingParams). Reading the wrong name leaves gl_account_id
|
||||||
|
// null, which the satellite table rejects, so each array declares its own field name here and the
|
||||||
|
// same name is written back when the pending edit is rebuilt into a product-update payload.
|
||||||
|
private static final String PAYMENT_CHANNEL_GL_FIELD = "fundSourceAccountId";
|
||||||
|
private static final String INCOME_ACCOUNT_GL_FIELD = "incomeAccountId";
|
||||||
|
|
||||||
public static List<ProductPendingEditGlMapping> extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) {
|
public static List<ProductPendingEditGlMapping> extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) {
|
||||||
final List<ProductPendingEditGlMapping> mappings = new java.util.ArrayList<>();
|
final List<ProductPendingEditGlMapping> mappings = new java.util.ArrayList<>();
|
||||||
boolean any = false;
|
boolean any = false;
|
||||||
any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL,
|
any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL,
|
||||||
"paymentTypeId", mappings);
|
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD, mappings);
|
||||||
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", mappings);
|
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId",
|
||||||
|
INCOME_ACCOUNT_GL_FIELD, mappings);
|
||||||
any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId",
|
any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId",
|
||||||
mappings);
|
INCOME_ACCOUNT_GL_FIELD, mappings);
|
||||||
return any ? mappings : null;
|
return any ? mappings : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName,
|
private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName,
|
||||||
final String mappingType, final String idFieldName, final List<ProductPendingEditGlMapping> out) {
|
final String mappingType, final String idFieldName, final String glFieldName,
|
||||||
|
final List<ProductPendingEditGlMapping> out) {
|
||||||
if (!command.hasParameter(paramName)) {
|
if (!command.hasParameter(paramName)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -625,8 +635,13 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
if (raw != null && raw.isJsonArray()) {
|
if (raw != null && raw.isJsonArray()) {
|
||||||
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
|
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
|
||||||
final JsonObject o = el.getAsJsonObject();
|
final JsonObject o = el.getAsJsonObject();
|
||||||
final Long idFieldValue = o.has(idFieldName) ? o.get(idFieldName).getAsLong() : null;
|
final Long idFieldValue = longOrNull(o, idFieldName);
|
||||||
final Long glAccountId = o.has("glAccountId") ? o.get("glAccountId").getAsLong() : null;
|
final Long glAccountId = longOrNull(o, glFieldName);
|
||||||
|
// A mapping is only meaningful with both halves present; keeping a half-filled row
|
||||||
|
// would fail the satellite table's NOT NULL constraint and abort the whole edit.
|
||||||
|
if (idFieldValue == null || glAccountId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null;
|
final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null;
|
||||||
final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null;
|
final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null;
|
||||||
out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId));
|
out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId));
|
||||||
@@ -635,15 +650,22 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Long longOrNull(final JsonObject o, final String fieldName) {
|
||||||
|
final com.google.gson.JsonElement el = o.get(fieldName);
|
||||||
|
return el == null || el.isJsonNull() ? null : el.getAsLong();
|
||||||
|
}
|
||||||
|
|
||||||
public static void writeGlArrayMappingsToJson(final JsonObject root, final List<ProductPendingEditGlMapping> mappings) {
|
public static void writeGlArrayMappingsToJson(final JsonObject root, final List<ProductPendingEditGlMapping> mappings) {
|
||||||
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings",
|
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings",
|
||||||
"paymentTypeId");
|
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD);
|
||||||
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId");
|
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId",
|
||||||
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId");
|
INCOME_ACCOUNT_GL_FIELD);
|
||||||
|
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId",
|
||||||
|
INCOME_ACCOUNT_GL_FIELD);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void writeGlArrayGroup(final JsonObject root, final List<ProductPendingEditGlMapping> mappings, final String type,
|
private static void writeGlArrayGroup(final JsonObject root, final List<ProductPendingEditGlMapping> mappings, final String type,
|
||||||
final String paramName, final String idFieldName) {
|
final String paramName, final String idFieldName, final String glFieldName) {
|
||||||
final JsonArray array = new JsonArray();
|
final JsonArray array = new JsonArray();
|
||||||
for (final ProductPendingEditGlMapping mapping : mappings) {
|
for (final ProductPendingEditGlMapping mapping : mappings) {
|
||||||
if (!type.equals(mapping.getMappingType())) {
|
if (!type.equals(mapping.getMappingType())) {
|
||||||
@@ -652,7 +674,7 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
final JsonObject entry = new JsonObject();
|
final JsonObject entry = new JsonObject();
|
||||||
final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId();
|
final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId();
|
||||||
entry.addProperty(idFieldName, idFieldValue);
|
entry.addProperty(idFieldName, idFieldValue);
|
||||||
entry.addProperty("glAccountId", mapping.getGlAccountId());
|
entry.addProperty(glFieldName, mapping.getGlAccountId());
|
||||||
array.add(entry);
|
array.add(entry);
|
||||||
}
|
}
|
||||||
root.add(paramName, array);
|
root.add(paramName, array);
|
||||||
|
|||||||
Reference in New Issue
Block a user