Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc2737ada | ||
|
|
f457b9b8c7 | ||
|
|
23ee4f1063 | ||
|
|
bc38216ca3 |
+2
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
|
||||
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
|
||||
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
|
||||
public static final String tenureInMonthsParamName = "tenureInMonths";
|
||||
public static final String tenureInDaysParamName = "tenureInDays";
|
||||
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
|
||||
|
||||
// Preclosure parameters
|
||||
@@ -511,6 +512,7 @@ public final class DepositsApiConstants {
|
||||
fixedDepositInterestCalculationData.add(principalAmountParamName);
|
||||
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
|
||||
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
|
||||
// Exact calendar-based calculation params
|
||||
|
||||
+97
-6
@@ -418,13 +418,20 @@ public class CashierDepositRequestApiResource {
|
||||
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
|
||||
amount, ptId, txnDateStr, description, true);
|
||||
} else {
|
||||
// Determine real account type by checking which table owns this ID.
|
||||
// Determine real account type by checking which table/column owns this ID.
|
||||
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
|
||||
// even for current accounts (submitted before the frontend fix).
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
|
||||
// the very same m_savings_account table as a plain savings account, distinguished only by
|
||||
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
|
||||
final String depositCategory = resolveDepositAccountCategory(savingsAccountId);
|
||||
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
|
||||
txnId = switch (depositCategory) {
|
||||
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
case "RECURRING_DEPOSIT" ->
|
||||
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
};
|
||||
if (!isCurrentAccount && txnId != null) {
|
||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
||||
@@ -718,6 +725,64 @@ public class CashierDepositRequestApiResource {
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String executeFixedDepositDeposit(
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.fixedDepositAccountDeposit(accountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String executeRecurringDepositDeposit(
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.recurringAccountDeposit(accountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
||||
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
||||
@@ -809,6 +874,32 @@ public class CashierDepositRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
|
||||
* rather than the request's own account_type string. FD and RD accounts live in the same
|
||||
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
|
||||
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
|
||||
* deposit command, which does not accept a term-deposit account id.
|
||||
*/
|
||||
private String resolveDepositAccountCategory(final Long accountId) {
|
||||
if (accountId == null) {
|
||||
return "SAVINGS";
|
||||
}
|
||||
if (isCurrentAccountById(accountId)) {
|
||||
return "CURRENT";
|
||||
}
|
||||
final List<Integer> depositTypes = jdbcTemplate.queryForList(
|
||||
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
|
||||
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
|
||||
if (Integer.valueOf(200).equals(depositType)) {
|
||||
return "FIXED_DEPOSIT";
|
||||
}
|
||||
if (Integer.valueOf(300).equals(depositType)) {
|
||||
return "RECURRING_DEPOSIT";
|
||||
}
|
||||
return "SAVINGS";
|
||||
}
|
||||
|
||||
// Parses a Fineract transaction id string to Long.
|
||||
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
|
||||
private Long parseTxnIdToLong(final String txnId) {
|
||||
|
||||
+4
@@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource {
|
||||
@QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
|
||||
@QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
|
||||
@QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
|
||||
@QueryParam("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays,
|
||||
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
|
||||
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths,
|
||||
@QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate,
|
||||
@@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource {
|
||||
HashMap request = new HashMap<>();
|
||||
request.put("annualInterestRate", annualInterestRate);
|
||||
request.put("tenureInMonths", tenureInMonths);
|
||||
if (tenureInDays != null) {
|
||||
request.put("tenureInDays", tenureInDays);
|
||||
}
|
||||
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
|
||||
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
|
||||
request.put("principalAmount", principalAmount);
|
||||
|
||||
+10
-1
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
|
||||
@@ -681,8 +682,16 @@ public class DepositAccountDataValidator {
|
||||
baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull()
|
||||
.notLessThanMin(BigDecimal.valueOf(0));
|
||||
|
||||
// FD Single accounts under 30 days are entered in whole days rather than months (a whole-month
|
||||
// tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not
|
||||
// an addition, so only one of the two needs to be a positive value.
|
||||
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
|
||||
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
|
||||
if (tenureInDays != null && tenureInDays > 0) {
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero();
|
||||
} else {
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
|
||||
}
|
||||
|
||||
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element);
|
||||
baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull()
|
||||
|
||||
+25
-12
@@ -414,20 +414,33 @@ public class SavingsAccountAssembler {
|
||||
// dependency on the shareaccounts domain class) rather than trusted blindly, the same way an
|
||||
// account number typed for a linked savings/loan account elsewhere in this form is verified
|
||||
// before being accepted.
|
||||
//
|
||||
// For a term deposit (FD/RD), the product screen labels this same flag "Deposit Account Link" --
|
||||
// it links an existing Savings/Current account (linkedAccountId, set below), not a co-op share
|
||||
// account, so it's validated against that field instead of shareAccountId.
|
||||
if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) {
|
||||
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
|
||||
if (shareAccountId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
|
||||
"A share account must be chosen when Share Linking is enabled.");
|
||||
final boolean isTermDeposit = account.depositAccountType().isFixedDeposit() || account.depositAccountType().isRecurringDeposit();
|
||||
if (isTermDeposit) {
|
||||
final Long linkedAccountIdForCheck = this.fromApiJsonHelper.extractLongNamed(linkedAccountIdParamName, element);
|
||||
if (linkedAccountIdForCheck == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.linked.account.required",
|
||||
"A Savings/Current account must be linked when Deposit Account Link is enabled.");
|
||||
}
|
||||
} else {
|
||||
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
|
||||
if (shareAccountId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
|
||||
"A share account must be chosen when Share Linking is enabled.");
|
||||
}
|
||||
final Integer matches = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
|
||||
shareAccountId, account.clientId());
|
||||
if (matches == null || matches == 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
|
||||
"Share account " + shareAccountId + " is not an active share account for this client.");
|
||||
}
|
||||
account.setShareAccountId(shareAccountId);
|
||||
}
|
||||
final Integer matches = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
|
||||
shareAccountId, account.clientId());
|
||||
if (matches == null || matches == 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
|
||||
"Share account " + shareAccountId + " is not an active share account for this client.");
|
||||
}
|
||||
account.setShareAccountId(shareAccountId);
|
||||
}
|
||||
|
||||
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
|
||||
|
||||
+20
-5
@@ -1512,6 +1512,23 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
if (depositAccountType.isFixedDeposit()) {
|
||||
((FixedDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
FixedDepositAccount fdAccount = ((FixedDepositAccount) account);
|
||||
|
||||
// Post the interest transaction(s) updateMaturityStatus() just created BEFORE any maturity
|
||||
// closure below runs. The closure's own withdrawal (handleWithdrawal -> postJournalEntries)
|
||||
// snapshots "existing" transactions the moment IT starts, which by then already includes
|
||||
// this interest posting -- so its own posting treats the interest as already accounted for
|
||||
// and skips it, while this method's own final postJournalEntries() call (previously relying
|
||||
// on the interest staying out of existingTransactionIds so it would post there instead)
|
||||
// never actually saw it get posted either: for an FD whose entire term is a single posting
|
||||
// period -- interest posts and the account matures in this same run -- that left the
|
||||
// interest transaction with a real ledger balance but no matching GL entry at all, forever.
|
||||
// Posting it now, while it is still the only new thing on the account, gives it exactly one
|
||||
// correct journal entry, and folding it into existingTransactionIds below (the same way
|
||||
// markWithdrawalsAsAlreadyPosted() already does for the withdrawal) keeps the closure branch
|
||||
// and the final call from ever trying to post it again.
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
// handle maturity instructions
|
||||
|
||||
if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) {
|
||||
@@ -1524,11 +1541,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
|
||||
// Every maturity-instruction branch takes the balance out of this account through
|
||||
// SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal
|
||||
// entries itself. existingTransactionIds was snapshotted at the top of this method --
|
||||
// before that withdrawal existed -- so the postJournalEntries() call at the end would
|
||||
// treat it as new and post it a second time. Interest postings created by
|
||||
// updateMaturityStatus() above are still genuinely unposted at this point and must stay
|
||||
// out of the snapshot so that final call can post them exactly once.
|
||||
// entries itself. existingTransactionIds was refreshed just above (after the interest was
|
||||
// posted) -- before that withdrawal existed -- so the postJournalEntries() call at the end
|
||||
// would treat it as new and post it a second time without this.
|
||||
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
|
||||
|
||||
if (changes.get("reinvestedDepositId") != null) {
|
||||
|
||||
+6
-3
@@ -22,6 +22,7 @@ package org.apache.fineract.portfolio.savings.service;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
@@ -52,6 +53,7 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
|
||||
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
|
||||
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
|
||||
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
|
||||
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
|
||||
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element);
|
||||
|
||||
// Optional params for exact calendar-based calculation
|
||||
@@ -62,8 +64,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
|
||||
if (dateStr != null && dateFormat != null) {
|
||||
submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
Integer daysInYear = null;
|
||||
final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element);
|
||||
@@ -73,7 +75,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
|
||||
BigDecimal maturityAmount;
|
||||
if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) {
|
||||
// Exact calendar-based calculation — same algorithm as account creation
|
||||
final LocalDate maturityDate = submittedOnDate.plusMonths(tenureInMonths);
|
||||
final LocalDate maturityDate = (tenureInDays != null && tenureInDays > 0) ? submittedOnDate.plusDays(tenureInDays)
|
||||
: submittedOnDate.plusMonths(tenureInMonths);
|
||||
maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate,
|
||||
interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user