Compare commits

..
Author SHA1 Message Date
niteshandClaude Sonnet 5 46f5a1babd feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / 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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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-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-1) (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 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 E2E Tests / E2E Tests (Shard 8 of 10) (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-4) (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 Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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 E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 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 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
WHAT THIS CHANGES
==================
Three related FD (Fixed Deposit) features, built incrementally in one session:

1. IN-PLACE RENEWAL -- when an FD matures with "Re-Invest Principal and
   Interest", the SAME m_savings_account row is now reused across every
   renewal (account number never changes) instead of closing the account
   and opening a new one. FixedDepositAccount.renewInPlace() does this by
   setting start_interest_calculation_date as a pivot: interest for terms
   before the pivot is never recalculated, only the new term's window is
   computed from there. A new one-to-many audit table m_fd_renewal_history
   records one row per term (renewal_no 0,1,2...) with that term's own
   principal, rate, dates, interest earned, maturity, and status. A
   companion table m_fd_renewal_chart_slab_history archives each closing
   term's full rate-by-tenure chart, captured just before renewInPlace
   overwrites it.

2. SENIOR-CITIZEN RATE -- the CBS Base Interest Rate master
   (m_cbs_base_interest_rate_slab_history) was the only place that ever
   stored a senior-citizen premium. It is now also persisted on
   m_savings_account_interest_rate_slab (new senior_citizen_rate column,
   nullable, added only to this account-level table -- NOT to the shared
   InterestRateChartSlabFields embeddable, so the product-level table
   m_interest_rate_slab is untouched). Applied both at account creation
   (DepositAccountAssembler now prices a new FD off the active CBS base
   version when one exists for that FD type, falling back to the
   product's own chart otherwise) and at renewal (renewInPlace refreshes
   the chart from the same base version).

3. PAYMENT ACTION -- a new manual command, POST
   /fixeddepositaccounts/{id}?command=payment, available whether the FD
   is ACTIVE or MATURED, unlike Close (Matured-only) and Premature Close
   (Active-only, no reinvest). Offers: Transfer to Savings, Re-Invest
   Principal and Interest (via renewInPlace), Re-Invest Principal Only
   (legacy close-and-reopen, now also transfers the leftover interest to
   a chosen savings account instead of stranding it), and a new closure
   type PARTIAL_REINVEST (500) -- part of the matured balance renews in
   place on the same account, the remainder transfers to a savings
   account picked at request time. Withdraw Deposit is deliberately
   excluded from this command. New permission PAYMENT_FIXEDDEPOSITACCOUNT
   plus its CHECKER twin were added in migration 0478.

KEY FILES
=========
- DepositAccountDomainServiceJpa.java: handleFDAccountPayment (new),
  renewInPlace call sites, recordInPlaceRenewal, senior-citizen rate
  matching with unit conversion across days/weeks and months/years
  families.
- FixedDepositAccount.java: renewInPlace, validateRenewalDate,
  isLockedOnDate, fdAccountNumber/renewalCycleNo chain tracking.
- SavingsAccount.java (SHARED with Savings and RD): added
  findLastTransactionBefore -- the interest-posting opening balance for a
  pivot-dated account now seeds from the last transaction strictly before
  the pivot, not the one on it. This fixes a bug where a renewed term
  could accrue zero interest, or double-subtract a same-day withdrawal,
  depending on transaction list order. Also widened
  calculateDateAccountIsLockedUntil to protected so renewInPlace can
  refresh lock-in on each renewed term.
- DepositAccountTransactionDataValidator.java: new validatePayment.
  validateClosing now explicitly rejects PARTIAL_REINVEST (500) so it can
  never reach the Close or Premature-Close handlers, which have no branch
  for it and would otherwise pay the whole balance out in cash. This
  applies to both FD and RD, since they share validateClosing.
- DepositAccountReadPlatformServiceImpl.java: retrieveOneWithClosureTemplate
  (Close and Premature Close, excludes PARTIAL_REINVEST) split from the
  new retrieveOneWithPaymentTemplate (Payment, includes it), sharing one
  private method with a flag. The renewal-history fallback query, for
  chains that predate m_fd_renewal_history, was rewritten to emit the
  exact same column shape as the primary query.
- New: FixedDepositRenewalHistory plus its repository,
  FixedDepositRenewalChartSlabHistory plus its repository,
  PaymentFixedDepositAccountCommandHandler.
- Migrations 0471 through 0478 under tenant/parts, each registered once
  in changelog-tenant.xml.

RD AND SAVINGS IMPACT -- READ THIS IF SOMETHING BREAKS THERE
============================================================
RD and plain Savings share several of the touched files. Verified during
this work:
  - handleRDAccountClosure and RecurringDepositAccount.java were not
    modified.
  - PARTIAL_REINVEST (500) is now rejected by validateClosing for both FD
    and RD close and prematureClose commands, and excluded from their
    template option lists. This was a real regression caught mid-session:
    the enum value briefly leaked into RD's Close dropdown as a working
    looking option that actually paid the whole RD balance out in cash.
    It was found and fixed before this commit.
  - The SavingsAccount.calculateInterestUsing opening-balance change only
    affects accounts where start_interest_calculation_date differs from
    activatedon_date. On the dev DB this was exactly two accounts, both
    FD test accounts created this session. Before deploying, run:
      SELECT deposit_type_enum, COUNT(*) FROM m_savings_account
      WHERE start_interest_calculation_date IS NOT NULL
        AND start_interest_calculation_date <> activatedon_date
      GROUP BY deposit_type_enum;
    Any non-FD rows (100 or 300) there mean a Savings or RD account will
    get a different, corrected interest amount than before. This is
    expected to be rare or zero, but confirm before deploy.
  - DepositAccountAssembler's creation-time base-rate pricing is guarded
    by depositAccountType.isFixedDeposit, so RD account creation is
    unaffected.
  - is_active on InterestRateChart (migration 0474) is read only by
    FixedDepositProduct.activeChart, which only FD's renewInPlace and
    reInvest call. RD's applicableChart path is untouched. Migration 0474
    also retires (is_active = false) any chart whose end_date is already
    past. On the dev DB this retired zero rows; check on the target DB
    before deploy, since there is currently no API to reactivate a
    retired chart.

KNOWN CAVEATS -- NOT YET DONE, FLAGGING RATHER THAN HIDING
============================================================
  - Migrations 0471 through 0477, the DDL ones, have never actually
    executed via Liquibase. They were mirrored by hand directly into the
    dev DB (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS) so
    testing could proceed without a full rebuild each time. Liquibase
    will mark them MARK_RAN via preConditions on the dev DB's next real
    run, but their first genuine execution will be on whatever
    environment does not already have these columns and tables, such as
    a fresh QA or production database. Test a migration-from-scratch run
    before relying on this in production.
  - This code has not been compiled. Every new or changed method
    signature, import, and call site was manually cross-checked with
    grep against real declarations, but that is not a substitute for
    gradle actually compiling it. Run a full build before deploying and
    treat any compiler error as expected until confirmed clean.
  - DepositAccountAssembler pricing every new FD off the active CBS base
    rate master, instead of the product's own chart, whenever one exists
    is a real behaviour change affecting all new FD accounts of a type
    that has a base-rate version, not just senior-citizen ones. Flagging
    this for explicit product sign-off, not assuming it in this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:44:11 +05:30
niteshandClaude Opus 5 20fe11e60d 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
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>
2026-08-21 12:57:02 +05:30
niteshandClaude Sonnet 5 eb1aa767f7 fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure
Fineract E2E Tests / E2E Tests (Shard 2 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 E2E Tests / E2E Tests (Shard 9 of 10) (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 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 E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (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 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-4) (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-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 Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Adds validation so Transfer-to-Savings can only target a real savings account,
with a proper user-facing message instead of the generic validation error.
Fixes three duplicate journal-entry bugs uncovered while testing FD/RD
maturity: the dormant-accounts job re-posting already-closed accounts
(escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure
flow itself re-deriving accounting for a withdrawal that the account-transfer
service had already posted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:04:23 +05:30
98 changed files with 2562 additions and 3530 deletions
@@ -137,7 +137,7 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
if (isApprovedByChecker || user.isCheckerSuperUser()) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,25 +151,6 @@ public class CommandSourceService {
return result;
}
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -2211,14 +2211,6 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createCbsBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "CBSBASEINTERESTRATE";
this.entityId = null;
this.href = "/fixeddepositproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -2905,6 +2897,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
this.actionName = "PAYMENT";
this.entityName = "FIXEDDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
return this;
}
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
this.actionName = "CALCULATEINTEREST";
this.entityName = "FIXEDDEPOSITACCOUNT";
@@ -26,8 +26,6 @@ public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
boolean isMakerCheckerEnabled();
/**
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
this.dataValidationErrors.add(error);
}
/**
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
* args passed to it).
*/
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
this.value, defaultUserMessageArgs);
this.dataValidationErrors.add(error);
}
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
// differentiate deposit accounts Savings, FD and RD accounts
private final EnumOptionData depositType;
/**
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
* for an FD that has never been renewed, and is null for non-deposit accounts.
*/
private final String fdAccountNumber;
/**
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
private final Integer value;
private final String code;
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
case 200 -> TRANSFER_TO_SAVINGS;
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
default -> INVALID;
};
}
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
return this.equals(INVALID);
}
public boolean isPartialReinvest() {
return this.equals(PARTIAL_REINVEST);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
break;
case PARTIAL_REINVEST:
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
}
return optionData;
}
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -61,11 +61,6 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return false;
}
@Override
public boolean isMakerCheckerEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
}
@Override
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
accountsSummary.append("sa.deposit_type_enum as depositType ");
accountsSummary.append("sa.deposit_type_enum as depositType, ");
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
// this row's cycle within the chain, so a client's account list can collapse a renewed
// FD into one entry rather than listing every term as a separate account.
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
// Null for a savings/current account and for an FD opened before this column existed;
// callers fall back to accountNo, so an FD that has never been renewed still reads as
// its own FD number.
final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate);
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
}
}
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
// Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
null, null);
}
}
@@ -440,6 +440,9 @@ public class FixedDepositAccountsApiResource {
} else if (is(commandParam, "prematureClose")) {
final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "payment")) {
final CommandWrapper commandRequest = builder.paymentFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "calculatePrematureAmount")) {
final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper);
@@ -453,7 +456,7 @@ public class FixedDepositAccountsApiResource {
if (result == null) {
throw new UnrecognizedQueryParamException("command", commandParam,
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
"postInterest", "close", "prematureClose", "payment", "calculatePrematureAmount" });
}
return this.toApiJsonSerializer.serialize(result);
@@ -489,6 +492,8 @@ public class FixedDepositAccountsApiResource {
DepositAccountData account = null;
if (is(commandParam, "close")) {
account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
} else if (is(commandParam, "payment")) {
account = this.depositAccountReadPlatformService.retrieveOneWithPaymentTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
}
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
@@ -29,10 +29,6 @@ import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
@@ -46,7 +42,6 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
private final PlatformSecurityContext context;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class,
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
@@ -82,11 +77,11 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save new FD base interest rate version", description = "Creates a new FD CBS base chart version for the given fdType and deactivates the previous active version.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public CommandProcessingResult saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.createCbsBaseInterestRate()
.withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
public String saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME);
final FdInterestRateSaveRequest request = gson.fromJson(apiRequestBodyAsJson, FdInterestRateSaveRequest.class);
final Long createdById = this.context.authenticatedUser().getId();
final FdInterestRateEffectiveData saved = this.fdBaseInterestRateReadPlatformService.saveInterestRates(request, createdById);
return gson.toJson(saved);
}
}
@@ -97,6 +97,7 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -105,11 +106,14 @@ public class DepositAccountDataValidator {
private final FromJsonHelper fromApiJsonHelper;
private final DepositProductDataValidator productDataValidator;
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
@Autowired
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator) {
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator,
final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
this.fromApiJsonHelper = fromApiJsonHelper;
this.productDataValidator = productDataValidator;
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
}
public void validateFixedDepositForSubmit(final String json) {
@@ -351,10 +355,40 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
}
@@ -493,10 +527,40 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
@@ -82,6 +82,13 @@ public class DepositAccountTransactionDataValidator {
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
DepositsApiConstants.reinvestAmountParamName));
@Autowired
public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
@@ -222,7 +229,15 @@ public class DepositAccountTransactionDataValidator {
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isTransferToSavings()) {
if (accountOnClosureType.isPartialReinvest()) {
// Partial Re-Invest needs a kept-amount and a destination account that only the Payment
// command collects, and only its handler implements. Neither the FD nor the RD closure
// handler has a branch for it, so without this it would fall through to their terminal
// "withdraw the whole balance" case and silently pay the deposit out in cash.
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"partial.reinvest.not.allowed",
"Partial Re-Invest is only supported through the Payment action, not account closure");
} else if (accountOnClosureType.isTransferToSavings()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName,
@@ -247,6 +262,73 @@ public class DepositAccountTransactionDataValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/**
* Validates the FD "Payment" command -- kept independent of {@link #validateClosing}, which stays exactly as-is
* for Close/Premature Close, so this never changes either of their existing behavior. Unlike
* {@code validateClosing}, reinvest is always allowed here (there is no {@code isPreMatureClose} restriction --
* Payment is meant to work on an Active account too), and a new Partial Reinvest closure type needs its own
* amount and destination-account rules.
*/
public void validatePayment(final JsonCommand command) {
final String json = command.json();
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(DepositAccountType.FIXED_DEPOSIT.resourceName());
final JsonElement element = command.parsedJson();
final LocalDate closedOnDate = this.fromApiJsonHelper.extractLocalDateNamed(closedOnDateParamName, element);
baseDataValidator.reset().parameter(closedOnDateParamName).value(closedOnDate).notNull();
final Integer onAccountClosureId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(onAccountClosureIdParamName, element);
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).notBlank()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isWithdarwDeposit()) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCodeAndMessage(
"withdraw.deposit.not.allowed.for.payment",
"Withdraw Deposit is not supported via Payment -- choose Transfer to Savings or a Reinvest option.");
}
// Interest earned under Reinvest Principal Only has to leave the deposit somewhere -- the same
// destination-account rule Transfer to Savings and Partial Reinvest already need.
if (accountOnClosureType.isTransferToSavings() || accountOnClosureType.isPartialReinvest()
|| accountOnClosureType.isReinvestPrincipal()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
}
if (accountOnClosureType.isPartialReinvest()) {
final BigDecimal reinvestAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(DepositsApiConstants.reinvestAmountParamName, element);
baseDataValidator.reset().parameter(DepositsApiConstants.reinvestAmountParamName).value(reinvestAmount).notNull()
.positiveAmount();
}
}
// Validate all string payment detail fields for max length
final Integer paymentTypeId = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(paymentTypeIdParamName, element);
baseDataValidator.reset().parameter(paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull().integerGreaterThanZero();
final Set<String> paymentDetailParameters = new HashSet<>(Arrays.asList(transactionAccountNumberParamName, checkNumberParamName,
routingCodeParamName, receiptNumberParamName, bankNumberParamName));
for (final String paymentDetailParameterName : paymentDetailParameters) {
final String paymentDetailParameterValue = this.fromApiJsonHelper.extractStringNamed(paymentDetailParameterName, element);
baseDataValidator.reset().parameter(paymentDetailParameterName).value(paymentDetailParameterValue).ignoreIfNull()
.notExceedingLengthOf(50);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
//
@@ -87,6 +87,7 @@ import org.apache.fineract.portfolio.group.exception.CenterNotActiveException;
import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException;
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
@@ -97,9 +98,11 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -123,6 +126,7 @@ public class DepositAccountAssembler {
private final ExternalIdFactory externalIdFactory;
private final SavingsAccountAssembler savingsAccountAssembler;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Autowired
public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper,
@@ -134,7 +138,8 @@ public class DepositAccountAssembler {
final RecurringDepositProductRepository recurringDepositProductRepository,
final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context,
final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory,
final SavingsAccountAssembler savingsAccountAssembler) {
final SavingsAccountAssembler savingsAccountAssembler,
final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService) {
this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper;
this.clientRepository = clientRepository;
@@ -151,6 +156,7 @@ public class DepositAccountAssembler {
this.paymentDetailAssembler = paymentDetailAssembler;
this.externalIdFactory = externalIdFactory;
this.savingsAccountAssembler = savingsAccountAssembler;
this.fdBaseInterestRateReadPlatformService = fdBaseInterestRateReadPlatformService;
}
/**
@@ -322,6 +328,28 @@ public class DepositAccountAssembler {
accountChart = DepositAccountInterestRateChart.from(productChart);
}
// A new FD priced from its product's own chart never carries a senior-citizen premium -- that
// concept exists only on the CBS Base Interest Rate master. So whenever staff hasn't picked a
// specific chart, price the account from that master's currently active version instead (the same
// one the Base Interest Rates screen shows and renewals already use), so a senior-citizen FD shows
// its premium from day one rather than only after its first renewal. RD is unaffected: this only
// runs when depositAccountType.isFixedDeposit(), and it's a no-op wherever no active base-rate
// version exists for the product's FD type, in which case the product's own chart above stands.
if (depositAccountType.isFixedDeposit() && accountChart != null && !command.parameterExists(chartIdParamName)
&& product instanceof FixedDepositProduct) {
final String fdType = ((FixedDepositProduct) product).getFdType();
if (fdType != null) {
final FdInterestRateEffectiveData activeBaseRates = this.fdBaseInterestRateReadPlatformService
.retrieveActiveInterestRatesByType(fdType).stream().findFirst().orElse(null);
if (activeBaseRates != null && activeBaseRates.getSlabs() != null && !activeBaseRates.getSlabs().isEmpty()) {
final InterestRateChartFields baseChartFields = DepositAccountDomainServiceJpa.baseChartFieldsOf(activeBaseRates);
final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs = DepositAccountDomainServiceJpa
.baseChartSlabsOf(activeBaseRates, product.currency().getCode());
accountChart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
}
}
}
boolean withHoldTax = product.withHoldTax();
if (command.parameterExists(withHoldTaxParamName)) {
withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName);
@@ -35,6 +35,14 @@ public interface DepositAccountDomainService {
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail);
/**
* As above, but lets the caller declare the deposit as an internal account-to-account movement rather
* than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight
* from the matured deposit and must post against the transfer-suspense GL instead of cash/vault.
*/
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer);
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
@@ -56,4 +64,7 @@ public interface DepositAccountDomainService {
Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
@@ -26,6 +27,9 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -55,12 +59,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository;
import org.apache.fineract.portfolio.calendar.domain.CalendarType;
import org.apache.fineract.portfolio.calendar.service.CalendarUtils;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
@@ -80,13 +90,28 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
private final CalendarInstanceRepository calendarInstanceRepository;
private final ExternalIdFactory externalIdFactory;
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Transactional
@Override
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction,
isAccountTransfer);
}
/**
* As above, but lets the caller declare the withdrawal as an internal account-to-account movement
* rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance
* moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault.
*/
private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) {
boolean isInterestTransfer = false;
boolean isWithdrawBalance = false;
final boolean backdatedTxnsAllowedTill = false;
@@ -100,7 +125,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer);
}
@Transactional
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer) {
boolean isRegularTransaction = false;
final boolean backdatedTxnsAllowedTill = false;
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
@@ -200,16 +233,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
// Captured before the withdrawal below empties the account, so the renewal history can
// record what this term actually matured at.
final BigDecimal maturedBalance = account.getAccountBalance();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(account.getAccountBalance(), externalId);
FixedDepositAccount reinvestedDeposit = account.reInvest(maturedBalance, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
// 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, maturedBalance, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// Reached when a user closes the account with a re-invest instruction, rather than the
// maturity job acting on a standing instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, maturedBalance, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
@@ -260,13 +303,58 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
BigDecimal reInvestAmount;
if (onClosureType.isReinvestPrincipal()) {
reInvestAmount = account.getDepositAmount();
} else {
reInvestAmount = account.getAccountBalance();
}
if (onClosureType.isReinvestPrincipalAndInterest()) {
// 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.
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
// 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
// new term's slabs and its senior-citizen premium, so screen and account agree.
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
// Captured before renewInPlace overwrites the account's chart, so the closing term's full
// rate-by-tenure chart (including its senior-citizen premiums) can be archived rather than lost
// -- renewInPlace mutates the same chart row every time, it does not create a new one.
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, 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,
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();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
@@ -276,18 +364,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction);
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
// postJournalEntries() call at the end of this method posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (onClosureType.isReinvestPrincipalAndInterest()) {
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
}
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);
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
@@ -304,6 +400,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
// if(!processMaturityInstructionOnly)
@@ -429,6 +526,274 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
}
/**
* Persists the closing term's full rate-by-tenure chart -- each slab together with its senior-citizen premium --
* against its {@link FixedDepositRenewalHistory} row.
* <p>
* Slabs missing a rate or a from-period are skipped -- they cannot describe a tenure band. Writes nothing when the
* closing term had no chart (e.g. a plain flat-rate deposit), rather than persisting an empty, meaningless archive.
*/
private void archiveChartSlabs(final Long fdRenewalHistoryId, final String chartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots, final String currencyCode) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
for (final DepositAccountInterestRateChart.SlabSnapshot snapshot : snapshots) {
final InterestRateChartSlabFields fields = snapshot.getFields();
if (fields.annualInterestRate() == null || fields.fromPeriod() == null) {
continue;
}
this.fixedDepositRenewalChartSlabHistoryRepository
.save(FixedDepositRenewalChartSlabHistory.create(fdRenewalHistoryId, chartName, fields.description(),
fields.periodType(), fields.fromPeriod(), fields.toPeriod(), fields.getAmountRangeFrom(),
fields.getAmountRangeTo(), fields.annualInterestRate(), currencyCode, snapshot.getSeniorCitizenRate()));
}
}
/**
* Chart identity for the account snapshot, so it names the base rate version it was priced from. Package-private
* and static (no instance state used) so {@link DepositAccountAssembler} can reuse the exact same mapping when a
* new FD account is first priced from this master, instead of duplicating the logic.
*/
static InterestRateChartFields baseChartFieldsOf(final FdInterestRateEffectiveData baseRates) {
// name and from_date are NOT NULL on the embeddable. A base version missing either would abort the
// whole renewal (or account creation) with a constraint violation, so treat it as "no identity to
// apply" instead: refreshFromSlabSnapshots keeps the chart's existing fields when handed null, and
// the slabs -- the part that actually prices the deposit -- still get applied.
if (baseRates == null || baseRates.getVersionLabel() == null || baseRates.getEffectiveFromDate() == null) {
return null;
}
final boolean isPrimaryGroupingByAmount = false; // the base rate card bands by tenure, never by amount
return InterestRateChartFields.createNew(baseRates.getVersionLabel(), baseRates.getRemarks(), baseRates.getEffectiveFromDate(),
baseRates.getEffectiveToDate(), isPrimaryGroupingByAmount);
}
/**
* Translates the base rate card's slabs into the shape an account chart stores, each paired with its senior-citizen
* premium so that figure is persisted on the account (and later archived) rather than only ever being looked up live.
* <p>
* The base master bands purely by tenure, so amount ranges are left unset -- which
* {@code InterestRateChartSlabFields.isAmountBetween} then treats as "any amount", exactly the intent. Slabs with no
* from-period are dropped, since a slab that does not say where it starts cannot be matched against a tenure.
*/
static Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabsOf(final FdInterestRateEffectiveData baseRates,
final String currencyCode) {
if (baseRates == null || baseRates.getSlabs() == null) {
return Collections.emptyList();
}
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots = new ArrayList<>();
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (slab.getFromPeriod() == null || slab.getAnnualInterestRate() == null) {
continue;
}
final InterestRateChartSlabFields fields = InterestRateChartSlabFields.createNew(slab.getDescription(),
SavingsPeriodFrequencyType.fromInt(slab.getPeriodTypeEnum()), slab.getFromPeriod(), slab.getToPeriod(), null, null,
slab.getAnnualInterestRate(), currencyCode);
snapshots.add(new DepositAccountInterestRateChart.SlabSnapshot(fields, slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* The senior-citizen premium from the active base rate card for a given tenure.
* <p>
* The tenure and the slab bounds are converted to a common unit before comparing, so a rate card published in years
* still matches a deposit taken for a number of months. Conversion stays inside a unit family -- days/weeks, or
* months/years -- because only those are exact; a days-based card is therefore still never matched against a
* months-based tenure, since "1 month" is not a fixed number of days and guessing would land the deposit in the
* wrong band. Null when no slab covers the tenure or the matching slab carries no premium, which leaves the
* account's existing rate untouched rather than silently dropping the depositor's premium.
*/
private BigDecimal seniorCitizenRateFor(final FdInterestRateEffectiveData baseRates, final Integer tenure,
final Integer tenurePeriodTypeEnum) {
if (baseRates == null || baseRates.getSlabs() == null || tenure == null) {
return null;
}
final Integer tenureInBaseUnit = inFamilyBaseUnit(tenure, tenurePeriodTypeEnum);
if (tenureInBaseUnit == null) {
return null;
}
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (!sameUnitFamily(tenurePeriodTypeEnum, slab.getPeriodTypeEnum())) {
continue;
}
final Integer slabFrom = inFamilyBaseUnit(slab.getFromPeriod(), slab.getPeriodTypeEnum());
if (slabFrom == null || tenureInBaseUnit < slabFrom) {
continue;
}
if (slab.getToPeriod() != null) {
final Integer slabTo = inFamilyBaseUnit(slab.getToPeriod(), slab.getPeriodTypeEnum());
if (slabTo == null || tenureInBaseUnit > slabTo) {
continue;
}
}
final BigDecimal rate = slab.getSeniorCitizenRate();
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
return rate;
}
}
return null;
}
/**
* {@code value} restated in the finest unit of its own family -- days for DAYS/WEEKS, months for MONTHS/YEARS -- so
* two periods written in different but related units can be compared without losing precision. Null for an unknown
* unit. Enum codes are {@link SavingsPeriodFrequencyType}: DAYS(0), WEEKS(1), MONTHS(2), YEARS(3).
*/
private Integer inFamilyBaseUnit(final Integer value, final Integer periodTypeEnum) {
if (value == null || periodTypeEnum == null) {
return null;
}
return switch (SavingsPeriodFrequencyType.fromInt(periodTypeEnum)) {
case DAYS, MONTHS -> value;
case WEEKS -> value * 7;
case YEARS -> value * 12;
default -> null;
};
}
/**
* Whether two period types convert exactly into one another. Days and weeks do; months and years do. Across those
* two families they do not, because a month is not a fixed number of days -- so a days-based rate card is never
* matched against a months-based tenure rather than guessing and landing the deposit in the wrong band.
*/
private boolean sameUnitFamily(final Integer firstPeriodTypeEnum, final Integer secondPeriodTypeEnum) {
if (firstPeriodTypeEnum == null || secondPeriodTypeEnum == null) {
return false;
}
return isDayFamily(firstPeriodTypeEnum) == isDayFamily(secondPeriodTypeEnum);
}
private boolean isDayFamily(final Integer periodTypeEnum) {
final SavingsPeriodFrequencyType type = SavingsPeriodFrequencyType.fromInt(periodTypeEnum);
return type == SavingsPeriodFrequencyType.DAYS || type == SavingsPeriodFrequencyType.WEEKS;
}
/**
* Records a renewal that happened on the SAME account, where there is no second account row to describe the new term.
* <p>
* Called after {@link FixedDepositAccount#renewInPlace}, which has already advanced the account's cycle number,
* principal and maturity date -- so the closing term's figures have to be passed in rather than read back off the
* account, where they no longer exist. Every row for one FD shares that FD's single account id and account number;
* they are told apart by renewal_no, which is what makes this a one-to-many history of one account.
*
* @param closingChartName
* the name of the chart the closing term was priced on, captured before renewInPlace overwrote it; null if
* unavailable
* @param closingChartSlabs
* the closing term's full rate-by-tenure chart, likewise captured before it was overwritten. Archived
* against the closing term's row so its whole rate card remains queryable, not only the single effective
* rate {@link FixedDepositRenewalHistory} records.
* @param closingInterestRate
* the closing term's own {@code nominalAnnualInterestRate}, captured before renewInPlace overwrote it --
* needed only when this term has no existing row yet (see below), since the account itself no longer
* carries it once renewInPlace has run.
* @param closingTenure
* the closing term's own deposit period, captured the same way and needed for the same reason.
* @param closingTenureFrequencyEnum
* the closing term's own deposit period frequency, captured the same way and needed for the same reason.
*/
private void recordInPlaceRenewal(final FixedDepositAccount account, final BigDecimal closingPrincipal,
final BigDecimal closingInterestEarned, final BigDecimal maturedBalance, final BigDecimal renewedAmount,
final LocalDate renewedOn, final String renewalType, final AppUser user, final String closingChartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs, final BigDecimal closingInterestRate,
final Integer closingTenure, final Integer closingTenureFrequencyEnum) {
// The CHAIN's number, not this account's own. They differ for an FD that was renewed under the old
// close-and-reopen model before renewing in place: its rows are keyed on the original account's
// number, so keying new rows on this account's number would split the history in two and re-create
// a duplicate row for the term that already has one. fdAccountNumber() falls back to this account's
// own number when there is no chain, which is what the read side resolves to as well.
final String fdAccountNumber = account.fdAccountNumber();
final int newCycleNo = account.renewalCycleNo();
final Long createdById = user == null ? null : user.getId();
final List<FixedDepositRenewalHistory> existingRows = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber);
FixedDepositRenewalHistory closingRow = existingRows.stream()
.filter(row -> Integer.valueOf(newCycleNo - 1).equals(row.getRenewalNo())).findFirst().orElse(null);
// The term that just closed has no row when this FD renews for the first time since the history
// table existed. Written now, from the figures captured before the rollover, so the history
// always begins with the term the customer originally opened -- rate and tenure included, rather
// than left blank, since the caller captured them before renewInPlace overwrote the account with
// the new term's own values.
if (closingRow == null) {
closingRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
}
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,
account.getNominalAnnualInterestRate(), renewedOn, account.maturityDate(), account.depositPeriod(),
account.depositPeriodFrequency(), renewalType, renewedOn, createdById));
}
/**
* Records the renewal that started {@code newTerm}, and closes off the previous term's history row.
* <p>
* Each term of an FD runs as its own deposit instance so it keeps an immutable ledger, which means the rollover event
* itself -- what the previous term earned, what carried forward, at what rate, automatically or by whom -- is not
* recoverable from the account rows alone. This writes it down explicitly.
* <p>
* The chain's first term is backfilled here too when it has no row yet: an FD opened before this table existed only
* gets discovered at its first renewal, and without its opening row the history would start at "Renewal 1" with no
* original.
*/
private void recordRenewal(final FixedDepositAccount previousTerm, final FixedDepositAccount newTerm,
final BigDecimal maturityAmount, final BigDecimal renewedAmount, final LocalDate renewedOn, final String renewalType,
final AppUser user) {
final String fdAccountNumber = previousTerm.fdAccountNumber();
final Long originalAccountId = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber).stream().findFirst()
.map(FixedDepositRenewalHistory::getOriginalSavingsAccountId)
.orElseGet(() -> previousTerm.reinvestedFromSavingsId() == null ? previousTerm.getId() : null);
FixedDepositRenewalHistory previousRow = this.fixedDepositRenewalHistoryRepository
.findBySavingsAccountIdOrderByRenewalNoDesc(previousTerm.getId()).stream().findFirst().orElse(null);
// Backfill the opening term when the chain started before this table existed, so the history
// always begins with the account the customer actually opened.
if (previousRow == null && originalAccountId != null && originalAccountId.equals(previousTerm.getId())) {
previousRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId, previousTerm.getId(), null, null, previousTerm.renewalCycleNo(),
previousTerm.getDepositAmount(), null, previousTerm.maturityAmount(), previousTerm.getDepositAmount(),
previousTerm.getNominalAnnualInterestRate(), previousTerm.getActivationDate(), previousTerm.maturityDate(),
previousTerm.depositPeriod(), previousTerm.depositPeriodFrequency(), renewalType, previousTerm.getActivationDate(),
user == null ? null : user.getId()));
}
// What the closing term actually earned. maturityAmount is passed in rather than read off the
// account because the caller has already withdrawn the balance out of it by this point, which
// would make it read as zero. Recorded on the CLOSING term's own row -- every row states its own
// term's outcome, so a reader never has to look at the next row to find out how one term did.
final BigDecimal previousPrincipal = previousTerm.getDepositAmount();
final BigDecimal interestEarned = maturityAmount == null || previousPrincipal == null ? null
: maturityAmount.subtract(previousPrincipal);
if (previousRow != null) {
previousRow.recordOutcome(interestEarned, maturityAmount);
previousRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(previousRow);
}
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId == null ? previousTerm.getId() : originalAccountId, newTerm.getId(), previousTerm.getId(),
previousRow == null ? null : previousRow.getId(), newTerm.renewalCycleNo(), renewedAmount, null, null, renewedAmount,
newTerm.getNominalAnnualInterestRate(), renewedOn, newTerm.maturityDate(), newTerm.depositPeriod(),
newTerm.depositPeriodFrequency(), renewalType, renewedOn, user == null ? null : user.getId()));
}
@Transactional
@Override
public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
@@ -474,6 +839,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
// accounted for or the postJournalEntries() call below posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.prematureClosure(user, command, changes);
@@ -484,6 +852,238 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
return savingsTransactionId;
}
/**
* 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.
* <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
* before.
*/
@Transactional
@Override
public Long handleFDAccountPayment(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
final JsonCommand command, final Map<String, Object> changes) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
boolean isRegularTransaction = false;
boolean isAccountTransfer = false;
final boolean isPreMatureClosure = false;
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
final MathContext mc = MathContext.DECIMAL64;
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
Long savingsTransactionId = null;
// account.isMatured() only reflects whether the overnight scheduler has already flipped this
// account's status -- not whether the customer actually showed up before or after the maturity
// date. A customer walking in ON the maturity date (or any day after) is not premature just
// because that job hasn't run yet today, so the real test is the date, not the status flag.
if (!account.isMatured()) {
if (!DateUtils.isBefore(closedDate, account.maturityDate())) {
// Genuinely due, the scheduler just hasn't caught up yet -- do exactly what it would:
// flip to Matured and post final interest at the full contracted rate, no penalty.
account.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
} else {
// The three closing closure types re-check this themselves inside prematureClosure()
// below, but the two reinvest branches never call it (they keep the account open on the
// same term), so it is enforced once, up front, for every genuinely-premature Payment --
// a locked-in FD (e.g. a Tax Saver with its statutory lock-in) cannot be touched early via
// this action either.
if (account.isLockedOnDate(closedDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.lockin.period.not.elapsed",
"This account is still within its lock-in period and cannot be processed before it elapses.", closedDate);
}
// Same penalty-aware interest calculation prematureClosure() already relies on, so a
// genuinely early Payment settles interest exactly as a Premature Close would.
account.postPreMaturityInterest(closedDate, true, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
} else {
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
// Same as the closure and maturity paths do: the legacy reinvest branch's reInvest() reads
// getClosedOnDate() to pick the new account's chart and submitted date, so it has to be set
// before branching. renewInPlace() clears it again for the branches that keep the account open.
account.setClosedOnDate(closedDate);
// Re-read after the block above: updateMaturityStatus() may just have flipped it, so this now
// correctly decides close() (needs Matured) vs prematureClosure() (needs Active) either way.
final boolean alreadyMatured = account.isMatured();
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.
account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
final BigDecimal renewedAmount;
if (onClosureType.isPartialReinvest()) {
renewedAmount = command.bigDecimalValueOfParameterNamed(reinvestAmountParamName);
if (renewedAmount == null || renewedAmount.compareTo(BigDecimal.ZERO) <= 0
|| renewedAmount.compareTo(maturedBalance) >= 0) {
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 {
renewedAmount = maturedBalance;
}
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
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, renewedAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
if (onClosureType.isPartialReinvest()) {
// 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.
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);
}
} else if (onClosureType.isTransferToSavings()) {
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, account.getAccountBalance(),
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);
}
} else {
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
}
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
return savingsTransactionId;
}
@Transactional
@Override
public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail,
@@ -555,14 +1155,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
List<SavingsAccountTransaction> transactions = savingsAccount.getTransactions();
int size = transactions.size();
for (int i = size - 1;; i--) {
SavingsAccountTransaction transaction = transactions.get(i);
if (transaction.isWithdrawal() || transaction.isWithdrawalFee()) {
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
// so that withdrawal must be added to existingTransactionIds here or the caller's own
// postJournalEntries() re-derives and re-posts it a second time. The previous implementation
// found it by walking the transaction list backwards from the end while the type stayed
// withdrawal/withdrawal-fee -- but @OneToMany collections only guarantee @OrderBy ordering on
// a fresh fetch, not after an in-memory append, so a same-day interest posting or any other
// ordering quirk could put the new withdrawal anywhere in the list and break the walk
// silently. Scanning every transaction and checking against the original snapshot is correct
// regardless of collection order.
for (final SavingsAccountTransaction transaction : savingsAccount.getTransactions()) {
if ((transaction.isWithdrawal() || transaction.isWithdrawalFee()) && !existingTransactionIds.contains(transaction.getId())) {
existingTransactionIds.add(transaction.getId());
} else {
break;
}
}
}
@@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.group.domain.Group;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
@@ -276,7 +277,9 @@ public class FixedDepositAccount extends SavingsAccount {
public LocalDate calculateMaturityDate() {
final LocalDate startDate = accountSubmittedOrActivationDate();
// The CURRENT term's start, not the account's activation date -- on a renewed FD these differ,
// and measuring the term from activation would put maturity in the past.
final LocalDate startDate = depositStartDate();
LocalDate maturityDate = null;
final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod();
switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) {
@@ -312,13 +315,19 @@ public class FixedDepositAccount extends SavingsAccount {
.fromInt(this.interestCalculationDaysInYearType);
List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
// Scoped to the current term, so a renewed FD's projection covers only the term now running --
// matching what postInterest() will actually post -- rather than replaying the whole account.
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
accountSubmittedOrActivationDate(), maturityDate, postingPeriodType, financialYearBeginningMonth,
postedAsOnTransactionDates);
depositStartDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates);
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
Money periodStartingBalance = Money.zero(currency);
// On a first term the opening deposit falls inside the first posting period, so the period can
// start from zero and pick the balance up from that transaction. On a renewed term that deposit
// is BEFORE the period range and would be missed, leaving the term to earn interest on nothing,
// so the term's own principal seeds the opening balance instead.
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
: Money.zero(currency);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure);
@@ -686,6 +695,60 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.maturityAmount();
}
/**
* The stable customer-facing FD number: the account number of the first account in this renewal chain. Falls back to
* this account's own number when it has never been renewed, so callers always get a usable FD number.
*/
public String fdAccountNumber() {
final String carried = this.accountTermAndPreClosure.getFdAccountNumber();
return carried != null ? carried : getAccountNumber();
}
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
public Integer renewalCycleNo() {
return this.accountTermAndPreClosure.getRenewalCycleNo();
}
public Integer depositPeriod() {
return this.accountTermAndPreClosure.depositPeriod();
}
public Integer depositPeriodFrequency() {
return this.accountTermAndPreClosure.depositPeriodFrequency();
}
public Long reinvestedFromSavingsId() {
return this.accountTermAndPreClosure.getReinvestedFromSavingsId();
}
/**
* The name of the chart currently priced on this account. Read before a renewal overwrites it, so the closing term's
* chart identity can be archived rather than lost.
*/
public String currentChartName() {
return this.chart == null ? null : this.chart.chartFields().getName();
}
/**
* The slabs of the chart currently priced on this account -- each paired with its senior-citizen premium -- detached
* from the chart's own persistence lifecycle.
* <p>
* Read before a renewal overwrites the account's chart in place ({@link #renewInPlace}), so the closing term's full
* rate-by-tenure breakdown (including the premium) can be archived -- not just the single effective rate the renewal
* history records -- rather than being silently deleted when the new term's slabs replace it.
*/
public List<DepositAccountInterestRateChart.SlabSnapshot> currentChartSlabFields() {
return this.chart == null ? java.util.Collections.emptyList() : this.chart.currentSlabSnapshots();
}
/**
* This deposit's FD type (SINGLE / DOUBLE / SPECIAL), which lives on the product rather than the account. Null when
* the product is not a Fixed Deposit product or has no type set.
*/
public String fdType() {
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
}
private Money totalInterestPosted() {
Money interestPostedToDate = Money.zero(this.currency);
for (final SavingsAccountTransaction transaction : this.transactions) {
@@ -715,11 +778,26 @@ public class FixedDepositAccount extends SavingsAccount {
return actualChanges;
}
/**
* The start of the term currently running, which is what the deposit's tenure and rate must be measured from.
* <p>
* For an FD that has never been renewed this is the activation date, exactly as before. A renewal advances
* startInterestCalculationDate to the renewal date, so from then on the interest-rate chart slab is chosen by THIS
* term's tenure rather than the cumulative tenure since the account was opened, and maturity is measured from the
* renewal rather than from the original activation.
*/
private LocalDate depositStartDate() {
// TODO: Support to add deposit start date which can be a date after
// account activation date.
final LocalDate depositStartDate = accountSubmittedOrActivationDate();
return depositStartDate;
final LocalDate currentTermStartDate = getStartInterestCalculationDate();
return currentTermStartDate != null ? currentTermStartDate : accountSubmittedOrActivationDate();
}
/**
* True once this FD has been renewed at least once, i.e. the term now running started later than the account did.
* Everything keyed off this keeps its original, pre-renewal behaviour for an FD still on its first term.
*/
private boolean isRenewedTerm() {
final LocalDate termStart = getStartInterestCalculationDate();
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
}
private LocalDate interestCalculatedUpto() {
@@ -814,28 +892,142 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.getTransferToSavingsAccountId();
}
/**
* Rolls this FD into a new term on the SAME account, so the customer keeps one account number however many times it
* renews. No new account is opened and no money moves: the balance simply carries into the next term.
* <p>
* What changes is the term itself. startInterestCalculationDate advances to the renewal date, which is what confines
* interest posting to the new term and thereby leaves the closing term's postings untouched -- they fall outside the
* posting periods the engine now generates, so nothing recalculates or reverses them. The term's principal becomes
* {@code renewedAmount}, and every interest-mechanics setting plus the rate chart is re-taken from the product as it
* stands today, so a renewal is priced on current terms rather than the ones frozen at account opening.
* <p>
* The caller must have posted the closing term's interest first, and must record the renewal in the FD's renewal
* history so each term's own figures remain reportable.
*
* @param renewedAmount
* what carries into the new term: the full maturity amount, or the principal only
* @param renewedOn
* the renewal date, which becomes the new term's start
* @param currentSeniorCitizenRate
* the senior-citizen premium the bank publishes on the renewal date for this FD type and tenure, or null to
* keep the rate the account already holds. Only applied to an account already flagged senior-citizen; the
* renewal never turns that flag on or off.
* @param baseChartFields
* identity of the CBS base rate version being applied, or null to keep the existing chart's
* @param baseChartSlabs
* the slabs of the active CBS base interest-rate version, each paired with its senior-citizen premium -- the
* rate card staff maintain and see on the Base Interest Rates screen. When empty or null the account falls
* back to the product's own chart, so a product with no base rate version still renews on something.
*/
public void renewInPlace(final BigDecimal renewedAmount, final LocalDate renewedOn, final BigDecimal currentSeniorCitizenRate,
final InterestRateChartFields baseChartFields, final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs) {
final SavingsProduct product = this.product;
// Confines interest posting to the new term. Set before recomputing maturity, since maturity is
// measured from the current term's start.
setStartInterestCalculationDate(renewedOn);
this.accountTermAndPreClosure.updateDepositAmount(renewedAmount);
this.accountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Pin the chain number now that this account has renewed at least once, so a non-zero cycle number
// is never paired with a blank FD number. fdAccountNumber() resolves to the chain's original number
// if there is one and to this account's own otherwise, which is the same value every reader derives.
this.accountTermAndPreClosure.setFdAccountNumber(fdAccountNumber());
// Re-price off the CURRENT product, exactly as opening a fresh deposit today would.
this.nominalAnnualInterestRate = product.nominalAnnualInterestRate();
this.interestCompoundingPeriodType = product.interestCompoundingPeriodType().getValue();
this.interestPostingPeriodType = product.interestPostingPeriodType().getValue();
this.interestCalculationType = product.interestCalculationType().getValue();
this.interestCalculationDaysInYearType = product.interestCalculationDaysInYearType().getValue();
this.withHoldTax = product.withHoldTax();
// Lock-in is re-taken from the product and re-measured from this renewal, exactly as the legacy
// close-and-reopen reInvest() does for the account it creates. Left alone, a renewed term would
// carry the lock-in window computed at the original activation -- long expired -- so a product with
// a statutory lock-in (a Tax Saver, say) would hold the customer for the first term only.
final SavingsPeriodFrequencyType renewedLockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
this.lockinPeriodFrequency = product.lockinPeriodFrequency();
this.lockinPeriodFrequencyType = renewedLockinPeriodFrequencyType == null ? null
: renewedLockinPeriodFrequencyType.getValue();
this.lockedInUntilDate = this.lockinPeriodFrequencyType == null || this.lockinPeriodFrequency == null ? null
: calculateDateAccountIsLockedUntil(renewedOn);
// The new term is priced off the CBS base interest-rate master -- the rate card staff maintain on
// the Base Interest Rates screen, per FD type, with its own active version. That master is what
// the bank publishes and what the account-opening wizard already shows, so pricing a renewal off
// anything else would put the deposit on rates nobody chose.
if (this.chart != null && baseChartSlabs != null && !baseChartSlabs.isEmpty()) {
this.chart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
} else if (this.chart != null) {
// No active base version for this FD type: fall back to the product's own chart so the
// renewal still gets current product rates rather than the ones frozen at opening.
// activeChart, not applicableChart -- the latter treats a chart with no end date as valid only
// up to today's business date, so a renewal dated in the future would find nothing.
final InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(renewedOn)
: null;
if (productChart != null) {
this.chart.refreshFrom(productChart);
}
}
// The senior-citizen premium is stored per account and takes priority over the chart in
// getEffectiveInterestRateAsFraction(), so leaving it alone would price every future term of a
// senior-citizen deposit at the rate captured when the account was opened. Only overwritten when
// the caller found a currently published rate -- otherwise the existing one stands, so a missing
// rate version can never silently drop the depositor's premium.
if (isSeniorCitizen() && currentSeniorCitizenRate != null && currentSeniorCitizenRate.compareTo(BigDecimal.ZERO) > 0) {
setSeniorCitizenInterestRate(currentSeniorCitizenRate);
}
// The account never left ACTIVE, but updateMaturityStatus() flipped it to MATURED just before
// this ran, so the new term has to put it back.
this.status = SavingsAccountStatusType.ACTIVE.getValue();
setClosedOnDate(null);
}
public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) {
final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount);
final SavingsProduct product = this.product;
final InterestRateChart productChart = product.applicableChart(getClosedOnDate());
// Effective-dated pick, same as renewInPlace: an open-ended chart stays valid past today's
// business date, and the most recently effective chart wins when more than one covers the date.
// Falls back to applicableChart so a product whose charts have no usable from-date still resolves
// as it did before.
InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(getClosedOnDate())
: null;
if (productChart == null) {
productChart = product.applicableChart(getClosedOnDate());
}
final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart);
final AccountType accountType = AccountType.fromInt(this.accountType);
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType);
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
.fromInt(this.interestCompoundingPeriodType);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
.fromInt(this.interestCalculationDaysInYearType);
// Re-price every interest-mechanics setting off the CURRENT product, not this (old) account's
// frozen-at-opening snapshot -- same reasoning as interestRate/newChart below. Without this, a
// renewed FD kept running on whatever compounding/posting/calculation-type/lock-in/tax setting
// was in force when the ORIGINAL account was opened, silently ignoring every product edit made
// since (e.g. Daily->Quarterly compounding, or a lock-in added later).
final SavingsPostingInterestPeriodType postingPeriodType = product.interestPostingPeriodType();
final SavingsCompoundingInterestPeriodType compoundingPeriodType = product.interestCompoundingPeriodType();
final SavingsInterestCalculationType interestCalculationType = product.interestCalculationType();
final SavingsInterestCalculationDaysInYearType daysInYearType = product.interestCalculationDaysInYearType();
final BigDecimal minRequiredOpeningBalance = null;
final BigDecimal interestRate = BigDecimal.ZERO;
// Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses
// for a brand-new account -- so a product with no chart slab covering this tenure/amount
// doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still
// re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the
// original account was opened) whenever a matching slab exists.
final BigDecimal interestRate = product.nominalAnnualInterestRate();
final Set<SavingsAccountCharge> savingsAccountCharges = null;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
final Integer lockinPeriodFrequency = this.lockinPeriodFrequency;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
final Integer lockinPeriodFrequency = product.lockinPeriodFrequency();
final boolean withdrawalFeeApplicableForTransfer = false;
final String accountNumber = null;
final boolean withHoldTax = this.withHoldTax;
final boolean withHoldTax = product.withHoldTax();
final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product,
savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType,
postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency,
@@ -845,6 +1037,30 @@ public class FixedDepositAccount extends SavingsAccount {
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
newChart.updateDepositAccountReference(reInvestedAccount);
// Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's
// reinvested FD silently drops back to the standard rate on the new term.
reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen());
reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate());
// Track which account this was reinvested from, so staff/UI can trace the two accounts to
// each other instead of the link disappearing the moment the old one closes.
newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId());
// Carry the ORIGINAL account's number forward across every renewal, so the customer keeps
// seeing one recognisable FD number even though each term is its own account/ledger row.
final String rootFdAccountNumber = this.accountTermAndPreClosure.getFdAccountNumber() != null
? this.accountTermAndPreClosure.getFdAccountNumber()
: this.getAccountNumber();
newAccountTermAndPreClosure.setFdAccountNumber(rootFdAccountNumber);
// Number this term within the chain, so the UI can label it "Renewal 2 of FD ...048".
newAccountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Without this the renewed deposit has no maturity instruction, so it would mature once more and
// then sit idle rather than rolling over again as the customer asked. Done here rather than in
// the shared copy(), which Recurring Deposit renewal also uses.
newAccountTermAndPreClosure.carryForwardStandingInstructionsFrom(this.accountTermAndPreClosure);
return reInvestedAccount;
}
@@ -886,7 +1102,9 @@ public class FixedDepositAccount extends SavingsAccount {
@Override
public void loadLazyCollections() {
super.loadLazyCollections();
this.chart.getId();
if (this.chart != null) {
this.chart.getId();
}
}
public BigDecimal getDepositAmount() {
@@ -902,6 +1120,52 @@ public class FixedDepositAccount extends SavingsAccount {
return SavingsAccountStatusType.MATURED.getValue().equals(this.status);
}
/**
* Exposes the parent class's protected lock-in check to callers outside this class hierarchy. Needed by the
* Payment action's domain-service method, which enforces this itself for its reinvest branches rather than going
* through {@link #prematureClosure}, since those branches keep the account open on the same term instead of
* closing it.
*/
public boolean isLockedOnDate(final LocalDate transactionDate) {
return isAccountLocked(transactionDate);
}
/**
* The date guards {@link #close} and {@link #prematureClosure} apply, minus the maturity-date bound, for a Payment
* that renews the deposit instead of closing it and so never reaches either of those methods.
* <p>
* Dropping the maturity bound is the point: Payment is allowed on an already-matured deposit. The other three
* matter more here than they do on a closure, because a renewal turns this date into the new term's interest
* pivot -- backdate it behind interest that has already been posted and the engine regenerates those posting
* periods, reverses the settled postings and re-posts them, which is exactly what the pivot exists to prevent.
*/
public void validateRenewalDate(final LocalDate renewedOn) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction);
if (DateUtils.isAfterBusinessDate(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("cannot.be.a.future.date");
}
if (DateUtils.isBefore(renewedOn, getActivationDate())) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.activation.date");
}
final List<SavingsAccountTransaction> savingsAccountTransactions = retrieveListOfTransactions();
if (!savingsAccountTransactions.isEmpty()) {
final SavingsAccountTransaction lastTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1);
if (lastTransaction.isAfter(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.last.transaction.date");
}
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
public void setClosedOnDate(final LocalDate closedOnDate) {
this.closedOnDate = closedOnDate;
}
@@ -0,0 +1,99 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One slab of the full tenure-by-rate chart a term of an FD was priced on, archived against that term's
* {@link FixedDepositRenewalHistory} row.
* <p>
* {@code renewInPlace} reuses the same account row across renewals, overwriting the account's own interest-rate chart in
* place at every renewal. {@code FixedDepositRenewalHistory} preserves the single effective rate that resulted, but not
* the chart it came from -- this table is what lets a later query answer "what did the whole rate card look like during
* term 2", not just "what rate applied".
*/
@Entity
@Table(name = "m_fd_renewal_chart_slab_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalChartSlabHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "fd_renewal_history_id", nullable = false)
private Long fdRenewalHistoryId;
@Column(name = "chart_name", length = 100)
private String chartName;
@Column(name = "description", length = 200)
private String description;
@Column(name = "period_type_enum")
private Integer periodTypeEnum;
@Column(name = "from_period")
private Integer fromPeriod;
@Column(name = "to_period")
private Integer toPeriod;
@Column(name = "amount_range_from", scale = 6, precision = 19)
private BigDecimal amountRangeFrom;
@Column(name = "amount_range_to", scale = 6, precision = 19)
private BigDecimal amountRangeTo;
@Column(name = "annual_interest_rate", scale = 6, precision = 19)
private BigDecimal annualInterestRate;
@Column(name = "currency_code", length = 3)
private String currencyCode;
/**
* The senior-citizen premium published for this tenure band at the time this term was priced, carried through from the
* CBS Base Interest Rates screen rather than looked up live against whatever is published now. Null when the term was
* priced off the product's own chart (which has no senior-citizen concept) or predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
public static FixedDepositRenewalChartSlabHistory create(final Long fdRenewalHistoryId, final String chartName,
final String description, final Integer periodTypeEnum, final Integer fromPeriod, final Integer toPeriod,
final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, final BigDecimal annualInterestRate,
final String currencyCode, final BigDecimal seniorCitizenRate) {
final FixedDepositRenewalChartSlabHistory slab = new FixedDepositRenewalChartSlabHistory();
slab.fdRenewalHistoryId = fdRenewalHistoryId;
slab.chartName = chartName;
slab.description = description;
slab.periodTypeEnum = periodTypeEnum;
slab.fromPeriod = fromPeriod;
slab.toPeriod = toPeriod;
slab.amountRangeFrom = amountRangeFrom;
slab.amountRangeTo = amountRangeTo;
slab.annualInterestRate = annualInterestRate;
slab.currencyCode = currencyCode;
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
}
@@ -0,0 +1,21 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalChartSlabHistoryRepository extends JpaRepository<FixedDepositRenewalChartSlabHistory, Long> {
/** The full chart a term was priced on, unordered -- callers typically sort by fromPeriod for display. */
List<FixedDepositRenewalChartSlabHistory> findByFdRenewalHistoryId(Long fdRenewalHistoryId);
}
@@ -0,0 +1,158 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One row per term of one logical Fixed Deposit, recording the renewal event that started it.
* <p>
* Each term runs as its own deposit instance (its own {@code m_savings_account} row) so that term keeps an immutable
* ledger, interest postings and GL entries, while {@link #fdAccountNumber} stays the same across the whole chain and is
* what the customer sees. What that structure alone cannot tell you is the intent and outcome of each rollover, which is
* what this table captures: the previous term's principal and interest earned, what it matured at, how much was carried
* forward, the rate applied to the new term, whether the renewal ran automatically at maturity or was performed by a
* user, and by whom.
*/
@Entity
@Table(name = "m_fd_renewal_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalHistory {
/** Renewal ran automatically when the maturity job processed the account's maturity instruction. */
public static final String RENEWAL_TYPE_AUTO = "AUTO";
/** A user closed the account with a re-invest instruction. */
public static final String RENEWAL_TYPE_MANUAL = "MANUAL";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_MATURED = "MATURED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The stable customer-facing FD number, shared by every term in the chain. */
@Column(name = "fd_account_number", length = 20, nullable = false)
private String fdAccountNumber;
/** The account the customer originally opened, i.e. the anchor of the chain. */
@Column(name = "original_savings_account_id", nullable = false)
private Long originalSavingsAccountId;
/** The deposit instance this term runs on. */
@Column(name = "savings_account_id", nullable = false)
private Long savingsAccountId;
/** The term this one was renewed from; null on the chain's first row. */
@Column(name = "previous_savings_account_id")
private Long previousSavingsAccountId;
@Column(name = "previous_renewal_id")
private Long previousRenewalId;
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
@Column(name = "renewal_no", nullable = false)
private Integer renewalNo;
@Column(name = "previous_principal", scale = 6, precision = 19)
private BigDecimal previousPrincipal;
@Column(name = "interest_earned", scale = 6, precision = 19)
private BigDecimal interestEarned;
@Column(name = "maturity_amount", scale = 6, precision = 19)
private BigDecimal maturityAmount;
/** What actually carried into this term: the full maturity amount, or principal only. */
@Column(name = "renewed_amount", scale = 6, precision = 19)
private BigDecimal renewedAmount;
@Column(name = "interest_rate", scale = 6, precision = 19)
private BigDecimal interestRate;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "tenure")
private Integer tenure;
@Column(name = "tenure_frequency_enum")
private Integer tenureFrequencyEnum;
@Column(name = "renewal_type", length = 10)
private String renewalType;
@Column(name = "renewal_status", length = 20)
private String renewalStatus;
@Column(name = "renewed_on")
private LocalDate renewedOn;
@Column(name = "created_by_id")
private Long createdById;
public static FixedDepositRenewalHistory create(final String fdAccountNumber, final Long originalSavingsAccountId,
final Long savingsAccountId, final Long previousSavingsAccountId, final Long previousRenewalId, final Integer renewalNo,
final BigDecimal previousPrincipal, final BigDecimal interestEarned, final BigDecimal maturityAmount,
final BigDecimal renewedAmount, final BigDecimal interestRate, final LocalDate startDate, final LocalDate maturityDate,
final Integer tenure, final Integer tenureFrequencyEnum, final String renewalType, final LocalDate renewedOn,
final Long createdById) {
final FixedDepositRenewalHistory history = new FixedDepositRenewalHistory();
history.fdAccountNumber = fdAccountNumber;
history.originalSavingsAccountId = originalSavingsAccountId;
history.savingsAccountId = savingsAccountId;
history.previousSavingsAccountId = previousSavingsAccountId;
history.previousRenewalId = previousRenewalId;
history.renewalNo = renewalNo;
history.previousPrincipal = previousPrincipal;
history.interestEarned = interestEarned;
history.maturityAmount = maturityAmount;
history.renewedAmount = renewedAmount;
history.interestRate = interestRate;
history.startDate = startDate;
history.maturityDate = maturityDate;
history.tenure = tenure;
history.tenureFrequencyEnum = tenureFrequencyEnum;
history.renewalType = renewalType;
history.renewalStatus = STATUS_ACTIVE;
history.renewedOn = renewedOn;
history.createdById = createdById;
return history;
}
/** Called on the previous term's row once its successor takes over. */
public void markMatured() {
this.renewalStatus = STATUS_MATURED;
}
/**
* Fills in what this term actually earned and matured at, which is only known once it closes. Written on the term's
* own row so each term reports its own outcome rather than that of its predecessor.
*/
public void recordOutcome(final BigDecimal interestEarned, final BigDecimal maturityAmount) {
this.interestEarned = interestEarned;
this.maturityAmount = maturityAmount;
}
}
@@ -0,0 +1,30 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalHistoryRepository extends JpaRepository<FixedDepositRenewalHistory, Long> {
/** One FD's whole history, oldest term first. */
List<FixedDepositRenewalHistory> findByFdAccountNumberOrderByRenewalNoAsc(String fdAccountNumber);
/**
* An account's terms, newest first.
* <p>
* Deliberately a list, not a single row: an FD that renews on the same account has one row per term all pointing at
* that one account, so callers wanting the term currently running take the first element. Empty for an account that
* pre-dates this table or was never renewed.
*/
List<FixedDepositRenewalHistory> findBySavingsAccountIdOrderByRenewalNoDesc(Long savingsAccountId);
}
@@ -18,25 +18,29 @@
*/
package org.apache.fineract.portfolio.savings.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CBSBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateCbsBaseInterestRateCommandHandler implements NewCommandSourceHandler {
@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT")
public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler {
private final FdBaseInterestRateWritePlatformService writePlatformService;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
@Autowired
public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) {
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.saveInterestRates(command);
return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command);
}
}
@@ -189,6 +189,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
.append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ")
.append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ")
.append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ")
.append("ircd.senior_citizen_rate as ircdSeniorCitizenRate, ")
.append("curr.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("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ")
@@ -346,6 +347,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom");
final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo");
final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate");
final BigDecimal seniorCitizenRate = rs.getBigDecimal("ircdSeniorCitizenRate");
// currency Slabs
final String currencyCode = rs.getString("currencyCode");
@@ -359,7 +361,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
currencyNameCode);
return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, currency);
amountRangeTo, annualInterestRate, currency, seniorCitizenRate);
}
}
@@ -46,6 +46,12 @@ public interface DepositAccountReadPlatformService {
DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId);
/**
* As {@link #retrieveOneWithClosureTemplate}, but for the FD Payment action -- so its option list also carries
* PARTIAL_REINVEST, which the closure commands deliberately exclude because only Payment implements it.
*/
DepositAccountData retrieveOneWithPaymentTemplate(DepositAccountType depositAccountType, Long accountId);
DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId);
Collection<SavingsAccountTransactionData> retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId);
@@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
}
}
if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) {
applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId);
}
}
return account;
@@ -329,6 +332,158 @@ 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);
}
// The number to actually show as "the FD account number" -- the original account's account_no
// if this account has been through a renewal, otherwise this account's own account_no.
final String termSql = "select fd_account_number, renewal_cycle_no from m_deposit_account_term_and_preclosure "
+ "where savings_account_id = ?";
final java.util.List<java.util.Map<String, Object>> termRows = this.jdbcTemplate.queryForList(termSql, accountId);
final String fdAccountNumber = termRows.isEmpty() ? null : (String) termRows.get(0).get("fd_account_number");
final Object cycleNo = termRows.isEmpty() ? null : termRows.get(0).get("renewal_cycle_no");
final String rootFdAccountNumber = fdAccountNumber != null ? fdAccountNumber : result.getAccountNo();
result.setFdAccountNumber(rootFdAccountNumber);
result.setRenewalCycleNo(cycleNo == null ? 0 : ((Number) cycleNo).intValue());
result.setRenewalHistory(retrieveRenewalHistory(rootFdAccountNumber));
}
/**
* Every term of one logical FD, oldest first.
* <p>
* Driven by m_fd_renewal_history, which records each rollover event -- interest the closing term earned, what it
* matured at, how much carried forward, the rate applied, automatic or by a user -- rather than deriving the chain by
* joining account rows, which cannot express any of that. The account row is joined in for its number and live
* status. Returns an empty list for an FD that has never been renewed, so callers can treat "has renewal history" as
* simply a non-empty list.
*/
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistory(final String fdAccountNumber) {
// Every row states its own term's figures, so this is a straight read: interest_earned and
// maturity_amount are filled in when that term closes, and stay null on the term still running,
// which the caller renders as its projected maturity instead.
final String sql = "select h.id as historyId, h.renewal_no as renewalCycleNo, h.savings_account_id as accountId, "
+ "sa.account_no as accountNo, h.renewed_amount as depositAmount, h.interest_rate as interestRate, "
+ "h.start_date as activatedOnDate, h.maturity_date as maturityDate, h.tenure as tenure, "
+ "h.tenure_frequency_enum as tenureFrequencyEnum, "
+ "h.renewal_type as renewalType, h.renewal_status as renewalStatus, sa.status_enum as statusEnum, "
+ "h.interest_earned as interestEarned, h.maturity_amount as maturedAmount, "
+ "t.maturity_amount as projectedMaturityAmount " + "from m_fd_renewal_history h "
+ "join m_savings_account sa on sa.id = h.savings_account_id "
+ "left join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where h.fd_account_number = ? " + "order by h.renewal_no asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber);
// Any row here is a renewal event that was actually recorded, so even one is worth returning --
// discarding a lone row used to hide a genuine renewal (and its interest figure) behind the
// account-derived fallback, which then returned nothing at all for it.
if (!rows.isEmpty()) {
attachArchivedChartSlabs(rows);
return rows;
}
// Chains that renewed before m_fd_renewal_history existed have no rows to read, so fall back to
// reconstructing what the account rows alone can tell us. The per-term interest/renewal-type
// columns are simply absent for those, which the UI renders as blanks rather than wrong values.
return retrieveRenewalHistoryFromAccounts(fdAccountNumber);
}
/**
* Attaches each term's archived rate-by-tenure chart (as a "chartSlabs" list) onto its own row.
* <p>
* A term's chart is only archived when it closes via an in-place renewal (see
* {@code DepositAccountDomainServiceJpa#archiveChartSlabs}), so the currently-running term and any term from before
* that archiving existed simply get an empty list here -- callers should fall back to the account's live
* accountChart.chartSlabs for those. Fetched in one batched query keyed by history id, not one query per row, since
* this runs once per FD account view.
*/
private void attachArchivedChartSlabs(final java.util.List<java.util.Map<String, Object>> rows) {
final java.util.List<Long> historyIds = new java.util.ArrayList<>();
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
if (id != null) {
historyIds.add(((Number) id).longValue());
}
}
if (historyIds.isEmpty()) {
return;
}
final String placeholders = String.join(",", java.util.Collections.nCopies(historyIds.size(), "?"));
final String sql = "select fd_renewal_history_id as historyId, chart_name as chartName, description, "
+ "period_type_enum as periodTypeEnum, from_period as fromPeriod, to_period as toPeriod, "
+ "annual_interest_rate as annualInterestRate, senior_citizen_rate as seniorCitizenRate "
+ "from m_fd_renewal_chart_slab_history " + "where fd_renewal_history_id in (" + placeholders + ") "
+ "order by from_period asc";
final java.util.List<java.util.Map<String, Object>> slabRows = this.jdbcTemplate.queryForList(sql, historyIds.toArray());
final java.util.Map<Long, java.util.List<java.util.Map<String, Object>>> slabsByHistoryId = new java.util.HashMap<>();
for (final java.util.Map<String, Object> slabRow : slabRows) {
final Long historyId = ((Number) slabRow.remove("historyId")).longValue();
slabsByHistoryId.computeIfAbsent(historyId, key -> new java.util.ArrayList<>()).add(slabRow);
}
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
final Long historyId = id == null ? null : ((Number) id).longValue();
row.put("chartSlabs", slabsByHistoryId.getOrDefault(historyId, java.util.Collections.emptyList()));
}
}
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistoryFromAccounts(final String fdAccountNumber) {
// Deliberately emits the SAME column labels as retrieveRenewalHistory above, so a caller never has
// to know which path produced the rows. What the account rows genuinely cannot tell us -- each
// term's interest earned, and whether the rollover was automatic or done by staff -- is returned as
// an explicit null rather than omitted, so those render as blanks instead of the previous behaviour
// where a missing renewalStatus made every term, including the running one, look matured.
final String sql = "select null as historyId, coalesce(t.renewal_cycle_no, 0) as renewalCycleNo, "
+ "sa.id as accountId, sa.account_no as accountNo, t.deposit_amount as depositAmount, "
+ "sa.nominal_annual_interest_rate as interestRate, sa.activatedon_date as activatedOnDate, "
+ "t.maturity_date as maturityDate, t.deposit_period as tenure, "
+ "t.deposit_period_frequency_enum as tenureFrequencyEnum, null as renewalType, "
+ "case when sa.status_enum = 300 then 'ACTIVE' else 'MATURED' end as renewalStatus, "
+ "sa.status_enum as statusEnum, null as interestEarned, "
+ "case when sa.status_enum = 300 then null else t.maturity_amount end as maturedAmount, "
+ "case when sa.status_enum = 300 then t.maturity_amount else null end as projectedMaturityAmount "
+ "from m_savings_account sa "
+ "join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where sa.account_no = ? or t.fd_account_number = ? " + "order by coalesce(t.renewal_cycle_no, 0) asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber,
fdAccountNumber);
// A single row here means this FD simply never renewed -- one account, one term -- so there is no
// history to show. Unlike the primary path, where even one recorded row is a real renewal event.
return rows.size() > 1 ? rows : java.util.Collections.emptyList();
}
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, "
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
@@ -349,8 +504,30 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
}, accountId);
}
/**
* Closure options for the {@code close} and {@code prematureClose} commands. PARTIAL_REINVEST is excluded: only the
* Payment command's handler implements it, and only its dialog collects the kept-amount and destination account it
* needs. Offered here it would appear in the FD and RD close dialogs, where the closure handlers have no branch for
* it and would fall through to paying the whole balance out in cash.
*/
@Override
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = false;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
/**
* The same template, but including PARTIAL_REINVEST -- for the Payment command, which is the one action that
* implements it.
*/
@Override
public DepositAccountData retrieveOneWithPaymentTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = true;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
private DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId,
final boolean includePartialReinvest) {
try {
this.context.authenticatedUser();
@@ -367,7 +544,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
depositAccountType.getValue());
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(DepositAccountOnClosureType.values());
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
.toArray(DepositAccountOnClosureType[]::new));
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
.retrieveActiveForLookup(account.getClientId(), DepositAccountType.SAVINGS_DEPOSIT);
@@ -554,7 +733,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
template = RecurringDepositAccountData.withInterestChartAndRecurringDetails((RecurringDepositAccountData) template,
accountChart, null, null);
// Re-apply CBS product fields lost during factory method object creation
@@ -626,7 +805,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
}
@@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -912,6 +913,67 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Override
public CommandProcessingResult processFdPayment(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
this.depositAccountTransactionDataValidator.validatePayment(command);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
// Payment intentionally spans both states -- unlike Close (Matured only) and Premature Close
// (Active only) -- but it must still be one of those two. Without this, a closed or
// pre-maturely-closed deposit could be reopened: the reinvest branches put the account back to
// ACTIVE and never reach the close()/prematureClosure() calls that would otherwise reject it.
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
if (!account.isActive() && !account.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.account.not.active.or.matured",
"Payment can only be processed on an active or matured fixed deposit account.", savingsId);
}
checkClientOrGroupActive(account);
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.
if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
final Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale());
final LocalDate closedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, closedOnDate, amountForDeposit.getAmount(), null,
isInternalTransferLeg);
}
final String noteText = command.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.savingNote(account, noteText);
changes.put("note", noteText);
this.noteRepository.save(note);
}
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
.withClientId(account.clientId()) //
.withGroupId(account.groupId()) //
.withSavingsId(savingsId) //
.with(changes)//
.build();
}
@Override
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
@@ -1393,18 +1455,37 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
// 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.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
Money amountForDeposit = account.activateWithBalance();
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
// activateWithBalance() always returns zero here -- it exists to stop a manually
// opened FD from auto-crediting itself at activation (funding is supposed to be a
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
// withdrawn from the matured account and needs to land in the new one as part of
// this same automated closure, so it has to come from what reInvest() actually
// carried over onto the new account, not from that unrelated activation guard.
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
// Matching internal-transfer leg for the withdrawal the closure just made on the
// matured account: the funds never touch cash, so this posts against the
// transfer-suspense GL instead of debiting cash/vault.
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
amountForDeposit.getAmount(), null);
amountForDeposit.getAmount(), null, isInternalTransferLeg);
}
}
} else if (depositAccountType.isRecurringDeposit()) {
@@ -1421,6 +1502,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
existingReversedTransactionIds.addAll(account.findExistingReversedTransactionIds());
}
/**
* Marks the account's withdrawal (and withdrawal-fee) transactions as already accounted for, so a
* subsequent postJournalEntries() built on an older snapshot does not post them again. Used after a
* maturity closure, whose withdrawal leg posts its own journal entries.
*/
private void markWithdrawalsAsAlreadyPosted(final SavingsAccount account, final Set<Long> existingTransactionIds) {
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
if (transaction.getId() != null && (transaction.isWithdrawal() || transaction.isWithdrawalFee())) {
existingTransactionIds.add(transaction.getId());
}
}
}
private void postJournalEntries(final SavingsAccount savingsAccount, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds) {
@@ -11,16 +11,10 @@
*/
package org.apache.fineract.portfolio.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
@@ -57,9 +51,7 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
@Override
@Transactional
public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) {
validateRequest(request);
final String fdType = request.getFdType().toUpperCase();
final String fdType = request.getFdType();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<FdBaseInterestRateEffective> activeVersions = effectiveRepository
@@ -93,125 +85,6 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
return toData(newVersion);
}
private void validateRequest(final FdInterestRateSaveRequest request) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
if (request == null) {
validator.failWithCodeNoParameterAddedToErrorCode("request.cannot.be.null");
throwExceptionIfValidationWarningsExist(errors);
return;
}
final String fdType = request.getFdType();
if (fdType == null || fdType.isBlank()) {
validator.parameter("fdType").failWithCode("cannot.be.blank");
} else if (!List.of("SINGLE", "DOUBLE", "SPECIAL").contains(fdType.toUpperCase())) {
validator.parameter("fdType").value(fdType).failWithCode("invalid.fd.type");
}
if (request.getSlabs() == null || request.getSlabs().isEmpty()) {
validator.parameter("slabs").failWithCode("cannot.be.blank");
}
if (request.getVersionLabel() != null && request.getVersionLabel().length() > 100) {
validator.parameter("versionLabel").value(request.getVersionLabel()).failWithCode("exceeds.max.length");
}
throwExceptionIfValidationWarningsExist(errors);
validateSlabs(request.getSlabs());
}
private void validateSlabs(final List<FdInterestRateSaveRequest.SlabRequest> slabs) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
if (slabs == null || slabs.isEmpty()) {
validator.parameter("slabs").failWithCode("cannot.be.blank");
throwExceptionIfValidationWarningsExist(errors);
return;
}
final List<FdInterestRateSaveRequest.SlabRequest> sorted = new ArrayList<>(slabs);
sorted.sort(Comparator.comparing((FdInterestRateSaveRequest.SlabRequest slab) -> slab.getSrNo() == null ? Integer.MAX_VALUE : slab.getSrNo())
.thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod()));
final Integer expectedPeriodType = sorted.get(0).getPeriodTypeEnum();
Integer expectedFrom = 1;
for (int i = 0; i < sorted.size(); i++) {
final FdInterestRateSaveRequest.SlabRequest slab = sorted.get(i);
final int srNo = slab.getSrNo() == null ? i + 1 : slab.getSrNo();
final String slabPrefix = "slabs[" + srNo + "].";
if (slab.getPeriodTypeEnum() == null) {
validator.parameter(slabPrefix + "periodTypeEnum").failWithCode("cannot.be.blank");
} else if (!slab.getPeriodTypeEnum().equals(expectedPeriodType)) {
validator.parameter(slabPrefix + "periodTypeEnum").value(slab.getPeriodTypeEnum())
.failWithCode("must.be.consistent.across.all.slabs");
}
final Integer fromPeriod = slab.getFromPeriod();
if (fromPeriod == null) {
validator.parameter(slabPrefix + "fromPeriod").failWithCode("cannot.be.blank");
} else if (fromPeriod < 0) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.non.negative");
} else if (!fromPeriod.equals(expectedFrom)) {
if (i == 0) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("first.slab.must.start.at.one");
} else if (fromPeriod < expectedFrom) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.not.overlap.previous.range");
} else {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.continuous.with.previous.range");
}
}
final Integer toPeriod = slab.getToPeriod();
if (i < sorted.size() - 1) {
if (toPeriod == null) {
validator.parameter(slabPrefix + "toPeriod").failWithCode("cannot.be.blank.for.intermediate.slab");
} else {
if (toPeriod < 0) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
}
if (fromPeriod != null && toPeriod < fromPeriod) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
}
expectedFrom = toPeriod + 1;
}
} else {
if (toPeriod != null) {
if (toPeriod < 0) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
}
if (fromPeriod != null && toPeriod < fromPeriod) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
}
}
}
final BigDecimal annualRate = slab.getAnnualInterestRate();
if (annualRate == null) {
validator.parameter(slabPrefix + "annualInterestRate").failWithCode("cannot.be.blank");
} else if (annualRate.compareTo(BigDecimal.ZERO) < 0) {
validator.parameter(slabPrefix + "annualInterestRate").value(annualRate).failWithCode("must.be.non.negative");
}
final BigDecimal seniorRate = slab.getSeniorCitizenRate();
if (seniorRate != null && seniorRate.compareTo(BigDecimal.ZERO) < 0) {
validator.parameter(slabPrefix + "seniorCitizenRate").value(seniorRate).failWithCode("must.be.non.negative");
}
}
throwExceptionIfValidationWarningsExist(errors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
private FdInterestRateEffectiveData toData(FdBaseInterestRateEffective e) {
List<FdInterestRateSlabData> slabs = slabRepository.findByEffectiveIdOrderBySrNo(e.getId()).stream()
.map(s -> FdInterestRateSlabData.builder()
@@ -1,27 +0,0 @@
/**
* 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.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface FdBaseInterestRateWritePlatformService {
CommandProcessingResult saveInterestRates(JsonCommand command);
}
@@ -1,49 +0,0 @@
/**
* 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.service;
import com.google.gson.Gson;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FdBaseInterestRateWritePlatformServiceImpl implements FdBaseInterestRateWritePlatformService {
private final PlatformSecurityContext context;
private final FdBaseInterestRateReadPlatformService readPlatformService;
private final Gson gson = new Gson();
@Override
public CommandProcessingResult saveInterestRates(final JsonCommand command) {
final FdInterestRateSaveRequest request = gson.fromJson(command.json(), FdInterestRateSaveRequest.class);
final Long createdById = this.context.authenticatedUser().getId();
final FdInterestRateEffectiveData saved = this.readPlatformService.saveInterestRates(request, createdById);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withEntityId(saved.getId())
.build();
}
}
@@ -1676,6 +1676,15 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusInactive(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Same snapshot-vs-execution gap as escheat() below: the dormant-accounts batch selects
// pending-inactive ids from a query that requires status = ACTIVE, but the account can close
// through another path (e.g. deposit maturity) before this call runs. Re-deriving accounting
// off the full transaction list at that point re-posts entries that closure already posted.
if (!account.isActive()) {
log.warn("Skipping setSubStatusInactive for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -1687,6 +1696,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusDormant(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
if (!account.isActive()) {
log.warn("Skipping setSubStatusDormant for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
account.setSubStatusDormant();
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@@ -1694,6 +1708,16 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void escheat(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// The dormant-accounts batch selects escheat-eligible ids from a snapshot query, so an
// account can legitimately close through another path (e.g. deposit maturity) in the gap
// between that selection and this call. escheat() unconditionally forces status/sub-status
// to CLOSED/ESCHEAT and re-derives accounting off the account's full transaction list, so
// running it against an already-closed account both mislabels the closure reason and
// re-posts journal entries for transactions that were already posted by the real closure.
if (!account.isActive()) {
log.warn("Skipping escheat for savings account {} -- no longer active (already closed by another process)", savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -169,8 +169,6 @@ import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatf
import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster;
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask;
import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService;
@@ -202,14 +200,6 @@ public class SavingsConfiguration {
return new FdBaseInterestRateReadPlatformServiceImpl(effectiveRepository, slabRepository);
}
@Bean
@ConditionalOnMissingBean(FdBaseInterestRateWritePlatformService.class)
public FdBaseInterestRateWritePlatformService fdBaseInterestRateWritePlatformService(
PlatformSecurityContext context,
FdBaseInterestRateReadPlatformService readPlatformService) {
return new FdBaseInterestRateWritePlatformServiceImpl(context, readPlatformService);
}
@Bean
@ConditionalOnMissingBean(SavingsAccountTransactionSearchService.class)
public SavingsAccountTransactionSearchService savingsAccountTransactionSearchService(PlatformSecurityContext context,
@@ -1,79 +0,0 @@
package org.apache.fineract.useradministration.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.data.AppUserPreferenceData;
import org.apache.fineract.useradministration.service.AppUserPreferenceReadPlatformService;
import org.apache.fineract.useradministration.service.AppUserPreferenceWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/users/{userId}/preferences")
@Component
@Tag(name = "User Preferences", description = "API to manage user specific preferences.")
@RequiredArgsConstructor
public class AppUserPreferencesApiResource {
private final PlatformSecurityContext context;
private final AppUserPreferenceReadPlatformService readPlatformService;
private final AppUserPreferenceWritePlatformService writePlatformService;
private void validateUserAuthorization(Long userId) {
if (!context.authenticatedUser().getId().equals(userId)) {
throw new NoAuthorizationException("Not authorized to access or modify preferences for another user.");
}
}
@GET
@Path("{preferenceName}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve user preference", description = "Retrieves a specific preference for the user.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class))) })
public AppUserPreferenceData getPreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName) {
validateUserAuthorization(userId);
String value = readPlatformService.getPreferenceValue(userId, preferenceName);
return new AppUserPreferenceData(preferenceName, value);
}
@PUT
@Path("{preferenceName}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update user preference", description = "Updates or creates a specific preference for the user.")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class)))
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public AppUserPreferenceData updatePreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName,
final AppUserPreferenceData preferenceData) {
validateUserAuthorization(userId);
// Ensure the path parameter matches the request body if the request body contains a name
String valueToSave = preferenceData != null ? preferenceData.getValue() : null;
if (valueToSave == null) {
throw new IllegalArgumentException("Preference value cannot be null");
}
writePlatformService.updatePreference(userId, preferenceName, valueToSave);
return new AppUserPreferenceData(preferenceName, valueToSave);
}
}
@@ -88,11 +88,6 @@ public class UsersApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
private final BulkImportWorkbookService bulkImportWorkbookService;
private final org.apache.fineract.useradministration.service.UserPendingWritePlatformService userPendingWritePlatformService;
private final org.apache.fineract.useradministration.service.UserPendingReadPlatformService userPendingReadPlatformService;
private final org.apache.fineract.infrastructure.core.serialization.FromJsonHelper fromApiJsonHelper;
private final org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService;
private final DefaultToApiJsonSerializer<org.apache.fineract.useradministration.data.UserPendingRequestData> pendingRequestApiJsonSerializer;
@GET
@Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n"
@@ -163,12 +158,6 @@ public class UsersApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.configurationDomainService.isMakerCheckerEnabled()) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(null, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.submitCreateUser(command);
return this.toApiJsonSerializer.serialize(result);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createUser() //
@@ -190,12 +179,6 @@ public class UsersApiResource {
@Produces({ MediaType.APPLICATION_JSON })
public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.configurationDomainService.isMakerCheckerEnabled()) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(userId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.submitUpdateUser(userId, command);
return this.toApiJsonSerializer.serialize(result);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateUser(userId) //
@@ -207,56 +190,6 @@ public class UsersApiResource {
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List User creations & edits pending approval")
public org.apache.fineract.infrastructure.core.service.Page<org.apache.fineract.useradministration.data.UserPendingRequestData> retrieveAllPendingUsers(
@QueryParam("status") final String status,
@QueryParam("username") final String username,
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate,
@QueryParam("offset") final Integer offset,
@QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.userPendingReadPlatformService.retrieveAllPendingRequests(status, username, fromDate, toDate, offset, limit);
}
@GET
@Path("pending/{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a pending user request by ID")
public String retrievePendingUser(@PathParam("id") final Long id, @Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final org.apache.fineract.useradministration.data.UserPendingRequestData data = this.userPendingReadPlatformService.retrievePendingRequest(id);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingRequestApiJsonSerializer.serialize(settings, data);
}
@POST
@Path("pending/{id}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending User creation or update")
public String approvePendingUser(@PathParam("id") final Long id) {
final CommandProcessingResult result = this.userPendingWritePlatformService.approvePendingRequest(id);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("pending/{id}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending User creation or update")
public String rejectPendingUser(@PathParam("id") final Long id, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(id, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.rejectPendingRequest(id, command);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{userId}/pwd")
@Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.")
@@ -1,13 +0,0 @@
package org.apache.fineract.useradministration.data;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppUserPreferenceData {
private String name;
private String value;
}
@@ -1,59 +0,0 @@
/**
* 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.useradministration.data;
import java.io.Serializable;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class UserPendingRequestData implements Serializable {
private static final long serialVersionUID = 1L;
private final Long id;
private final Long userId;
private final String requestType;
private final String status;
private final String username;
private final String firstname;
private final String lastname;
private final String email;
private final String mobileNo;
private final Long officeId;
private final String officeName;
private final Long staffId;
private final String staffName;
private final String otpDeliveryMethod;
private final Boolean passwordNeverExpires;
private final Collection<String> roleNames;
private final Long makerId;
private final String makerUsername;
private final OffsetDateTime submittedDate;
private final Long checkerId;
private final String checkerUsername;
private final OffsetDateTime checkedDate;
private final String rejectReason;
private final Map<String, Object> originalValues;
private final Map<String, Object> proposedValues;
}
@@ -1,54 +0,0 @@
package org.apache.fineract.useradministration.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_appuser_preference")
public class AppUserPreference extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "appuser_id", nullable = false)
private AppUser appUser;
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "value", nullable = false)
private String value;
protected AppUserPreference() {
//
}
private AppUserPreference(final AppUser appUser, final String name, final String value) {
this.appUser = appUser;
this.name = name;
this.value = value;
}
public static AppUserPreference create(final AppUser appUser, final String name, final String value) {
return new AppUserPreference(appUser, name, value);
}
public void updateValue(final String newValue) {
this.value = newValue;
}
public String getName() {
return this.name;
}
public String getValue() {
return this.value;
}
public AppUser getAppUser() {
return this.appUser;
}
}
@@ -1,10 +0,0 @@
package org.apache.fineract.useradministration.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AppUserPreferenceRepository extends JpaRepository<AppUserPreference, Long> {
Optional<AppUserPreference> findByAppUserIdAndName(Long appUserId, String name);
}
@@ -1,113 +0,0 @@
/**
* 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.useradministration.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_user_pending_request")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class UserPendingRequest extends AbstractPersistableCustom<Long> {
@Column(name = "user_id")
private Long userId;
@Column(name = "request_type", nullable = false, length = 20)
private String requestType;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "payload_json", nullable = false)
private String payloadJson;
@Column(name = "original_json")
private String originalJson;
@Column(name = "password_hash")
private String passwordHash;
@Column(name = "maker_id", nullable = false)
private Long makerId;
@Column(name = "submitted_date", nullable = false)
private OffsetDateTime submittedDate;
@Column(name = "checker_id")
private Long checkerId;
@Column(name = "checked_date")
private OffsetDateTime checkedDate;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
@Column(name = "pending_lock_key", length = 100, unique = true)
private String pendingLockKey;
public static UserPendingRequest create(final UserPendingRequestType requestType, final Long userId,
final String payloadJson, final String originalJson, final String passwordHash,
final Long makerId, final String pendingLockKey) {
final UserPendingRequest request = new UserPendingRequest();
request.requestType = requestType.name();
request.userId = userId;
request.status = UserPendingRequestStatus.PENDING.name();
request.payloadJson = payloadJson;
request.originalJson = originalJson;
request.passwordHash = passwordHash;
request.makerId = makerId;
request.submittedDate = OffsetDateTime.now(ZoneOffset.UTC);
request.pendingLockKey = pendingLockKey;
return request;
}
public boolean isPending() {
return UserPendingRequestStatus.PENDING.name().equals(this.status);
}
public boolean isMadeBy(final Long appUserId) {
return this.makerId != null && this.makerId.equals(appUserId);
}
public void approve(final Long checkerId) {
this.status = UserPendingRequestStatus.APPROVED.name();
this.checkerId = checkerId;
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
this.pendingLockKey = null;
}
public void reject(final Long checkerId, final String reason) {
this.status = UserPendingRequestStatus.REJECTED.name();
this.checkerId = checkerId;
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -1,30 +0,0 @@
/**
* 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.useradministration.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface UserPendingRequestRepository extends JpaRepository<UserPendingRequest, Long>, JpaSpecificationExecutor<UserPendingRequest> {
Optional<UserPendingRequest> findByPendingLockKey(String pendingLockKey);
boolean existsByPendingLockKey(String pendingLockKey);
}
@@ -1,49 +0,0 @@
/**
* 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.useradministration.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.exception.UserPendingRequestNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserPendingRequestRepositoryWrapper {
private final UserPendingRequestRepository repository;
@Transactional(readOnly = true)
public UserPendingRequest findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new UserPendingRequestNotFoundException(id));
}
public UserPendingRequest saveAndFlush(final UserPendingRequest request) {
return this.repository.saveAndFlush(request);
}
public UserPendingRequest save(final UserPendingRequest request) {
return this.repository.save(request);
}
@Transactional(readOnly = true)
public boolean existsByPendingLockKey(final String pendingLockKey) {
return this.repository.existsByPendingLockKey(pendingLockKey);
}
}
@@ -1,37 +0,0 @@
/**
* 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.useradministration.domain;
public enum UserPendingRequestStatus {
PENDING,
APPROVED,
REJECTED;
public static UserPendingRequestStatus fromString(final String status) {
if (status == null) {
return null;
}
for (final UserPendingRequestStatus requestStatus : values()) {
if (requestStatus.name().equalsIgnoreCase(status)) {
return requestStatus;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status);
}
}
@@ -1,36 +0,0 @@
/**
* 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.useradministration.domain;
public enum UserPendingRequestType {
CREATE_USER,
UPDATE_USER;
public static UserPendingRequestType fromString(final String type) {
if (type == null) {
return null;
}
for (final UserPendingRequestType requestType : values()) {
if (requestType.name().equalsIgnoreCase(type)) {
return requestType;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type);
}
}
@@ -1,28 +0,0 @@
/**
* 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.useradministration.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException {
public UserPendingRequestNotFoundException(final Long id) {
super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id);
}
}
@@ -1,5 +0,0 @@
package org.apache.fineract.useradministration.service;
public interface AppUserPreferenceReadPlatformService {
String getPreferenceValue(Long appUserId, String name);
}
@@ -1,20 +0,0 @@
package org.apache.fineract.useradministration.service;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.domain.AppUserPreference;
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AppUserPreferenceReadPlatformServiceImpl implements AppUserPreferenceReadPlatformService {
private final AppUserPreferenceRepository appUserPreferenceRepository;
@Override
public String getPreferenceValue(Long appUserId, String name) {
Optional<AppUserPreference> pref = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name);
return pref.map(AppUserPreference::getValue).orElse(null);
}
}
@@ -1,5 +0,0 @@
package org.apache.fineract.useradministration.service;
public interface AppUserPreferenceWritePlatformService {
void updatePreference(Long appUserId, String name, String value);
}
@@ -1,32 +0,0 @@
package org.apache.fineract.useradministration.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserPreference;
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AppUserPreferenceWritePlatformServiceImpl implements AppUserPreferenceWritePlatformService {
private final AppUserPreferenceRepository appUserPreferenceRepository;
private final AppUserRepository appUserRepository;
@Transactional
@Override
public void updatePreference(Long appUserId, String name, String value) {
AppUserPreference preference = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name).orElse(null);
if (preference == null) {
AppUser appUser = appUserRepository.findById(appUserId)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.user.id.invalid", "User not found"));
preference = AppUserPreference.create(appUser, name, value);
} else {
preference.updateValue(value);
}
appUserPreferenceRepository.saveAndFlush(preference);
}
}
@@ -1,30 +0,0 @@
/**
* 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.useradministration.service;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
public interface UserPendingReadPlatformService {
Page<UserPendingRequestData> retrieveAllPendingRequests(String status, String username, String fromDate, String toDate,
Integer offset, Integer limit);
UserPendingRequestData retrievePendingRequest(Long id);
}
@@ -1,329 +0,0 @@
/**
* 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.useradministration.service;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
import org.apache.fineract.useradministration.domain.UserPendingRequest;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private static final String SELECT_SQL = """
SELECT r.id AS id,
r.user_id AS userId,
r.request_type AS requestType,
r.status AS status,
r.payload_json AS payloadJson,
r.original_json AS originalJson,
r.maker_id AS makerId,
maker.username AS makerUsername,
r.submitted_date AS submittedDate,
r.checker_id AS checkerId,
checker.username AS checkerUsername,
r.checked_date AS checkedDate,
r.reject_reason AS rejectReason
FROM m_user_pending_request r
JOIN m_appuser maker ON maker.id = r.maker_id
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
""";
@Override
@Transactional(readOnly = true)
public Page<UserPendingRequestData> retrieveAllPendingRequests(final String status, final String username,
final String fromDate, final String toDate, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" WHERE 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
where.append(" AND r.status = ?");
args.add(status.trim().toUpperCase());
} else {
where.append(" AND r.status = 'PENDING'");
}
if (StringUtils.isNotBlank(fromDate)) {
where.append(" AND r.submitted_date::date >= ?::date");
args.add(fromDate);
}
if (StringUtils.isNotBlank(toDate)) {
where.append(" AND r.submitted_date::date <= ?::date");
args.add(toDate);
}
final String countSql = "SELECT COUNT(*) FROM m_user_pending_request r " + where;
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final int pageOffset = offset == null ? 0 : offset;
final int pageLimit = limit == null ? 20 : limit;
final String pageSql = SELECT_SQL + where + " ORDER BY r.submitted_date DESC LIMIT ? OFFSET ?";
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(pageLimit);
pageArgs.add(pageOffset);
final List<UserPendingRequestData> items = this.jdbcTemplate.query(pageSql, new UserPendingRowMapper(), pageArgs.toArray());
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
@Override
@Transactional(readOnly = true)
public UserPendingRequestData retrievePendingRequest(final Long id) {
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
final String sql = SELECT_SQL + " WHERE r.id = ?";
return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
}
private static class UserPendingRowMapper implements RowMapper<UserPendingRequestData> {
@Override
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long userId = JdbcSupport.getLong(rs, "userId");
final String requestType = rs.getString("requestType");
final String status = rs.getString("status");
final String payloadJson = rs.getString("payloadJson");
final Long makerId = rs.getLong("makerId");
final String makerUsername = rs.getString("makerUsername");
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
final String checkerUsername = rs.getString("checkerUsername");
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final String rejectReason = rs.getString("rejectReason");
String username = null;
String firstname = null;
String lastname = null;
String email = null;
String mobileNo = null;
Long officeId = null;
String otpDeliveryMethod = null;
try {
if (payloadJson != null) {
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
if (payloadObj.has("username")) {
username = payloadObj.get("username").getAsString();
}
if (payloadObj.has("firstname")) {
firstname = payloadObj.get("firstname").getAsString();
}
if (payloadObj.has("lastname")) {
lastname = payloadObj.get("lastname").getAsString();
}
if (payloadObj.has("email")) {
email = payloadObj.get("email").getAsString();
}
if (payloadObj.has("mobileNo")) {
mobileNo = payloadObj.get("mobileNo").getAsString();
}
if (payloadObj.has("officeId")) {
officeId = payloadObj.get("officeId").getAsLong();
}
if (payloadObj.has("otpDeliveryMethod")) {
otpDeliveryMethod = payloadObj.get("otpDeliveryMethod").getAsString();
}
}
} catch (Exception ignored) {
}
return UserPendingRequestData.builder()
.id(id)
.userId(userId)
.requestType(requestType)
.status(status)
.username(username)
.firstname(firstname)
.lastname(lastname)
.email(email)
.mobileNo(mobileNo)
.officeId(officeId)
.otpDeliveryMethod(otpDeliveryMethod)
.makerId(makerId)
.makerUsername(makerUsername)
.submittedDate(submittedDate)
.checkerId(checkerId)
.checkerUsername(checkerUsername)
.checkedDate(checkedDate)
.rejectReason(rejectReason)
.build();
}
}
private static class UserPendingDetailRowMapper implements RowMapper<UserPendingRequestData> {
@Override
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long userId = JdbcSupport.getLong(rs, "userId");
final String requestType = rs.getString("requestType");
final String status = rs.getString("status");
final String payloadJson = rs.getString("payloadJson");
final String originalJson = rs.getString("originalJson");
final Long makerId = rs.getLong("makerId");
final String makerUsername = rs.getString("makerUsername");
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
final String checkerUsername = rs.getString("checkerUsername");
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final String rejectReason = rs.getString("rejectReason");
final Map<String, Object> proposedValues = new LinkedHashMap<>();
final Map<String, Object> originalValues = new LinkedHashMap<>();
String username = null;
String firstname = null;
String lastname = null;
String email = null;
String mobileNo = null;
Long officeId = null;
String otpDeliveryMethod = null;
Boolean passwordNeverExpires = null;
try {
if (payloadJson != null) {
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
for (final String key : payloadObj.keySet()) {
final JsonElement val = payloadObj.get(key);
if ("password".equals(key) || "repeatPassword".equals(key)) {
proposedValues.put("password", "Password will be set on approval");
} else if (val.isJsonPrimitive()) {
if (val.getAsJsonPrimitive().isBoolean()) {
proposedValues.put(key, val.getAsBoolean());
} else if (val.getAsJsonPrimitive().isNumber()) {
proposedValues.put(key, val.getAsNumber());
} else {
proposedValues.put(key, val.getAsString());
}
} else if (val.isJsonArray()) {
final List<String> list = new ArrayList<>();
for (final JsonElement elem : val.getAsJsonArray()) {
list.add(elem.getAsString());
}
proposedValues.put(key, list);
}
}
if (proposedValues.containsKey("username")) {
username = (String) proposedValues.get("username");
}
if (proposedValues.containsKey("firstname")) {
firstname = (String) proposedValues.get("firstname");
}
if (proposedValues.containsKey("lastname")) {
lastname = (String) proposedValues.get("lastname");
}
if (proposedValues.containsKey("email")) {
email = (String) proposedValues.get("email");
}
if (proposedValues.containsKey("mobileNo")) {
mobileNo = (String) proposedValues.get("mobileNo");
}
if (proposedValues.get("officeId") instanceof Number n) {
officeId = n.longValue();
}
if (proposedValues.containsKey("otpDeliveryMethod")) {
otpDeliveryMethod = (String) proposedValues.get("otpDeliveryMethod");
}
if (proposedValues.get("passwordNeverExpires") instanceof Boolean b) {
passwordNeverExpires = b;
}
}
if (originalJson != null) {
final JsonObject origObj = JsonParser.parseString(originalJson).getAsJsonObject();
for (final String key : origObj.keySet()) {
final JsonElement val = origObj.get(key);
if (val.isJsonPrimitive()) {
if (val.getAsJsonPrimitive().isBoolean()) {
originalValues.put(key, val.getAsBoolean());
} else if (val.getAsJsonPrimitive().isNumber()) {
originalValues.put(key, val.getAsNumber());
} else {
originalValues.put(key, val.getAsString());
}
} else if (val.isJsonArray()) {
final List<String> list = new ArrayList<>();
for (final JsonElement elem : val.getAsJsonArray()) {
list.add(elem.getAsString());
}
originalValues.put(key, list);
}
}
if (proposedValues.containsKey("password")) {
originalValues.put("password", "[PROTECTED]");
}
}
} catch (Exception ignored) {
}
return UserPendingRequestData.builder()
.id(id)
.userId(userId)
.requestType(requestType)
.status(status)
.username(username)
.firstname(firstname)
.lastname(lastname)
.email(email)
.mobileNo(mobileNo)
.officeId(officeId)
.otpDeliveryMethod(otpDeliveryMethod)
.passwordNeverExpires(passwordNeverExpires)
.makerId(makerId)
.makerUsername(makerUsername)
.submittedDate(submittedDate)
.checkerId(checkerId)
.checkerUsername(checkerUsername)
.checkedDate(checkedDate)
.rejectReason(rejectReason)
.originalValues(originalValues)
.proposedValues(proposedValues)
.build();
}
}
}
@@ -1,33 +0,0 @@
/**
* 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.useradministration.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface UserPendingWritePlatformService {
CommandProcessingResult submitCreateUser(JsonCommand command);
CommandProcessingResult submitUpdateUser(Long userId, JsonCommand command);
CommandProcessingResult approvePendingRequest(Long id);
CommandProcessingResult rejectPendingRequest(Long id, JsonCommand command);
}
@@ -1,386 +0,0 @@
/**
* 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.useradministration.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
import org.apache.fineract.organisation.staff.domain.Staff;
import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.domain.Role;
import org.apache.fineract.useradministration.domain.RoleRepository;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.apache.fineract.useradministration.domain.UserPendingRequest;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepository;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
import org.apache.fineract.useradministration.domain.UserPendingRequestType;
import org.apache.fineract.useradministration.exception.RoleNotFoundException;
import org.apache.fineract.useradministration.exception.UserNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlatformService {
private final PlatformSecurityContext context;
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
private final UserPendingRequestRepository pendingRequestRepository;
private final AppUserRepository appUserRepository;
private final OfficeRepositoryWrapper officeRepositoryWrapper;
private final RoleRepository roleRepository;
private final StaffRepositoryWrapper staffRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final UserMfaFactorRepository userMfaFactorRepository;
private final ConfigurationDomainService configurationDomainService;
private final PasswordEncoder passwordEncoder;
private final UserDataValidator fromApiJsonDeserializer;
private final FromJsonHelper fromApiJsonHelper;
@Override
@Transactional
public CommandProcessingResult submitCreateUser(final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
this.fromApiJsonDeserializer.validateForCreate(command.json());
final String username = command.stringValueOfParameterNamed("username");
final String email = command.stringValueOfParameterNamed("email");
final String mobileNo = command.stringValueOfParameterNamed("mobileNo");
// Pre-flight checks on live users and pending creations
if (this.appUserRepository.findAppUserByName(username) != null) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.username",
"User with username " + username + " already exists.", "username", username);
}
final String lockKey = "CREATE_USER:" + username.toLowerCase();
if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate",
"A pending creation request for username '" + username + "' is already awaiting checker approval.", username);
}
assertEmailAndMobileUnique(email, mobileNo, null);
final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject();
String passwordHash = null;
if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) {
final String rawPassword = jsonObject.get("password").getAsString();
if (!rawPassword.isBlank()) {
passwordHash = this.passwordEncoder.encode(rawPassword);
}
jsonObject.addProperty("password", "[PROTECTED]");
if (jsonObject.has("repeatPassword")) {
jsonObject.addProperty("repeatPassword", "[PROTECTED]");
}
}
final String sanitizedPayload = jsonObject.toString();
final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.CREATE_USER, null,
sanitizedPayload, null, passwordHash, maker.getId(), lockKey);
final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(saved.getId())
.setRollbackTransaction(true)
.build();
}
@Override
@Transactional
public CommandProcessingResult submitUpdateUser(final Long userId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final AppUser userToUpdate = this.appUserRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
this.fromApiJsonDeserializer.validateForUpdate(command.json(), maker);
final String email = command.stringValueOfParameterNamed("email");
final String mobileNo = command.stringValueOfParameterNamed("mobileNo");
assertEmailAndMobileUnique(email, mobileNo, userId);
final String lockKey = "UPDATE_USER:" + userId;
if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate",
"A pending update request for user ID " + userId + " is already awaiting checker approval.", userId);
}
// Build point-in-time original JSON snapshot
final Map<String, Object> originalSnapshot = new LinkedHashMap<>();
originalSnapshot.put("username", userToUpdate.getUsername());
originalSnapshot.put("firstname", userToUpdate.getFirstname());
originalSnapshot.put("lastname", userToUpdate.getLastname());
originalSnapshot.put("email", userToUpdate.getEmail());
originalSnapshot.put("mobileNo", userToUpdate.getMobileNo());
originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null);
originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null);
originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null);
originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires());
originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser());
final Set<String> roleNames = new HashSet<>();
final Set<Long> roleIds = new HashSet<>();
if (userToUpdate.getRoles() != null) {
for (final Role r : userToUpdate.getRoles()) {
roleNames.add(r.getName());
roleIds.add(r.getId());
}
}
originalSnapshot.put("roles", roleIds);
originalSnapshot.put("roleNames", roleNames);
final String originalJson = GoogleGsonSerializerHelper.createSimpleGson().toJson(originalSnapshot);
final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject();
String passwordHash = null;
if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) {
final String rawPassword = jsonObject.get("password").getAsString();
if (!rawPassword.isBlank()) {
passwordHash = this.passwordEncoder.encode(rawPassword);
}
jsonObject.addProperty("password", "[PROTECTED]");
if (jsonObject.has("repeatPassword")) {
jsonObject.addProperty("repeatPassword", "[PROTECTED]");
}
}
final String sanitizedPayload = jsonObject.toString();
final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.UPDATE_USER, userId,
sanitizedPayload, originalJson, passwordHash, maker.getId(), lockKey);
final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(saved.getId())
.setRollbackTransaction(true)
.build();
}
@Override
@Transactional
public CommandProcessingResult approvePendingRequest(final Long id) {
final AppUser checker = this.context.authenticatedUser();
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
if (!request.isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending",
"User pending request " + id + " has already been actioned.", id);
}
if (request.isMadeBy(checker.getId())) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval",
"You cannot approve a user request you submitted yourself — a different user must action it.", id);
}
final Long resourceId;
if (UserPendingRequestType.CREATE_USER.name().equals(request.getRequestType())) {
resourceId = finalizeCreateUser(request);
} else if (UserPendingRequestType.UPDATE_USER.name().equals(request.getRequestType())) {
resourceId = finalizeUpdateUser(request);
} else {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.type.invalid",
"Unknown request type: " + request.getRequestType(), request.getRequestType());
}
request.approve(checker.getId());
this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(resourceId)
.withResourceIdAsString(resourceId.toString())
.build();
}
@Override
@Transactional
public CommandProcessingResult rejectPendingRequest(final Long id, final JsonCommand command) {
final AppUser checker = this.context.authenticatedUser();
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
if (!request.isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending",
"User pending request " + id + " has already been actioned.", id);
}
if (request.isMadeBy(checker.getId())) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval",
"You cannot reject a user request you submitted yourself — a different user must action it.", id);
}
String reason = command != null ? command.stringValueOfParameterNamedAllowingNull("reason") : null;
if (reason == null && command != null) {
reason = command.stringValueOfParameterNamedAllowingNull("rejectReason");
}
if (reason == null || reason.isBlank()) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.reject.reason.required",
"A reason is required to reject a user pending request.", id);
}
request.reject(checker.getId(), reason);
this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(request.getId())
.withResourceIdAsString(request.getId().toString())
.build();
}
private Long finalizeCreateUser(final UserPendingRequest request) {
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
final JsonCommand replay = JsonCommand.fromJsonElement(null, parsedElement, this.fromApiJsonHelper);
final Long officeId = replay.longValueOfParameterNamed("officeId");
final Office userOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
final String[] roles = replay.arrayValueOfParameterNamed("roles");
final Set<Role> allRoles = assembleSetOfRoles(roles);
final Long staffId = replay.longValueOfParameterNamed("staffId");
final Staff linkedStaff = staffId != null
? this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userOffice.getId())
: null;
final Collection<Client> clients = null;
final AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, allRoles, clients, replay);
if (this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
appUser.updatePasswordResetRequired(true);
}
// Set pre-hashed password
if (request.getPasswordHash() != null) {
appUser.updatePassword(request.getPasswordHash());
}
this.appUserRepository.saveAndFlush(appUser);
// MFA Enrollment
final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod");
if (otpDeliveryMethod != null) {
final MfaFactorType factorType = MfaFactorType.fromString(otpDeliveryMethod);
final UserMfaFactor factor = new UserMfaFactor();
factor.setAppUser(appUser);
factor.setFactorType(factorType);
factor.setDestination(factorType == MfaFactorType.SMS ? appUser.getMobileNo() : appUser.getEmail());
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
this.userMfaFactorRepository.save(factor);
}
return appUser.getId();
}
private Long finalizeUpdateUser(final UserPendingRequest request) {
final AppUser userToUpdate = this.appUserRepository.findById(request.getUserId())
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
if (changes.containsKey("officeId")) {
final Long officeId = (Long) changes.get("officeId");
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
userToUpdate.changeOffice(office);
}
if (changes.containsKey("staffId")) {
final Long staffId = (Long) changes.get("staffId");
Staff linkedStaff = null;
if (staffId != null) {
linkedStaff = this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userToUpdate.getOffice().getId());
}
userToUpdate.changeStaff(linkedStaff);
}
if (changes.containsKey("roles")) {
final String[] roleIds = (String[]) changes.get("roles");
final Set<Role> allRoles = assembleSetOfRoles(roleIds);
userToUpdate.updateRoles(allRoles);
}
if (request.getPasswordHash() != null) {
userToUpdate.updatePassword(request.getPasswordHash());
}
this.appUserRepository.saveAndFlush(userToUpdate);
return userToUpdate.getId();
}
private Set<Role> assembleSetOfRoles(final String[] rolesArray) {
final Set<Role> allRoles = new HashSet<>();
if (!ObjectUtils.isEmpty(rolesArray)) {
for (final String roleId : rolesArray) {
final Long id = Long.valueOf(roleId);
final Role role = this.roleRepository.findById(id).orElseThrow(() -> new RoleNotFoundException(id));
allRoles.add(role);
}
}
return allRoles;
}
private void assertEmailAndMobileUnique(final String email, final String mobileNo, final Long excludeUserId) {
if (email != null && !email.isBlank()) {
final java.util.Optional<AppUser> emailOwner = excludeUserId == null
? this.appUserRepository.findByEmailIgnoreCase(email)
: this.appUserRepository.findByEmailIgnoreCaseAndIdNot(email, excludeUserId);
if (emailOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.email",
"User with email " + email + " already exists.", "email", email);
}
}
if (mobileNo != null && !mobileNo.isBlank()) {
final java.util.Optional<AppUser> mobileOwner = excludeUserId == null
? this.appUserRepository.findByMobileNo(mobileNo)
: this.appUserRepository.findByMobileNoAndIdNot(mobileNo, excludeUserId);
if (mobileOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.mobile",
"User with mobile number " + mobileNo + " already exists.", "mobileNo", mobileNo);
}
}
}
}
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -537,12 +537,13 @@
<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/0469_create_database_export_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0466_create_m_appuser_preference.xml" relativeToChangelogFile="true"/>
<include file="parts/0467_enable_glaccount_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0470_enable_cbs_base_interest_rate_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0471_enable_charge_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0472_enable_product_creation_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0474_enable_fixed_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0475_enable_admin_user_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0476_add_user_pending_request_and_enable_charge_mc.xml" relativeToChangelogFile="true"/>
<include file="parts/0470_add_reinvested_from_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0471_add_fd_account_number_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0472_add_renewal_cycle_no_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0473_create_fd_renewal_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0474_add_is_active_to_interest_rate_chart.xml" relativeToChangelogFile="true"/>
<include file="parts/0475_create_fd_renewal_chart_slab_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml" relativeToChangelogFile="true"/>
<include file="parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0478_add_fd_payment_permission.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -1,24 +0,0 @@
<?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.3.xsd">
<changeSet id="0466_create_m_appuser_preference" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_appuser_preference"/></not></preConditions>
<createTable tableName="m_appuser_preference">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="name" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="value" type="TEXT">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_appuser_preference" baseColumnNames="appuser_id" constraintName="fk_m_appuser_preference_appuser_id" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addUniqueConstraint tableName="m_appuser_preference" columnNames="appuser_id, name" constraintName="uq_appuser_preference_name"/>
</changeSet>
</databaseChangeLog>
@@ -1,23 +0,0 @@
<?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">
<changeSet id="0467_enable_glaccount_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="3">
SELECT COUNT(*)
FROM m_permission
WHERE entity_name = 'GLACCOUNT'
AND action_name IN ('CREATE', 'UPDATE', 'DELETE')
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'GLACCOUNT'
AND action_name IN ('CREATE', 'UPDATE', 'DELETE');
</sql>
</changeSet>
</databaseChangeLog>
@@ -7,7 +7,6 @@
address as verified or not verified once the outcome is known. Deliberately a plain column rather than
a foreign key: the dispatch is a historical record and must survive the address being edited or removed. -->
<changeSet id="0468_add_address_id_to_welcome_kit_dispatch" author="CBS" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_welcome_kit_dispatch" columnName="address_id"/>
@@ -7,7 +7,6 @@
person's authority. The requester states a reason, an admin approves or rejects, and only an approved
request can produce a dump. Every step is stamped so the trail survives the file itself. -->
<changeSet id="0469_create_database_export_request" author="CBS" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<createTable tableName="m_database_export_request">
<column name="id" type="BIGSERIAL">
<constraints primaryKey="true" nullable="false"/>
@@ -51,7 +50,6 @@
<!-- Requesting an export and approving one are deliberately separate permissions: the whole control
depends on no single user holding both. -->
<changeSet id="0469_database_export_permissions" author="CBS" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'authorisation', 'CREATE_DATABASEEXPORT', 'DATABASEEXPORT', 'CREATE', false
@@ -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>
@@ -1,68 +0,0 @@
<?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">
<changeSet id="0470_cbs_base_interest_rate_maker_checker_permissions" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSBASEINTERESTRATE', 'CBSBASEINTERESTRATE', 'CREATE', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSBASEINTERESTRATE');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSBASEINTERESTRATE_CHECKER', 'CBSBASEINTERESTRATE', 'CREATE_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSBASEINTERESTRATE_CHECKER');
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'CBSBASEINTERESTRATE'
AND action_name = 'CREATE';
UPDATE m_permission
SET action_name = 'CREATE_CHECKER'
WHERE entity_name = 'CBSBASEINTERESTRATE'
AND code = 'CREATE_CBSBASEINTERESTRATE_CHECKER';
</sql>
</changeSet>
<changeSet id="0470_grant_cbs_base_interest_rate_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?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">
<!-- FD renewal ("Re-Invest") always opens a brand new account with its own account_no, since
account_no is unique and the matured account keeps its own row for audit history. Customers
and staff still expect to recognise "their FD" by one number across renewals though, so this
column carries the ORIGINAL account's account_no forward onto every account created by
reinvesting it (and its reinvestments, and so on). Left null on an account that has never been
reinvested; the read side falls back to that account's own account_no in that case, so only
accounts past their first renewal need this populated. -->
<changeSet id="0471_add_fd_account_number_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="fd_account_number"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="fd_account_number" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -1,63 +0,0 @@
<?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">
<changeSet id="0471_enable_charge_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="1">
SELECT COUNT(*)
FROM m_permission
WHERE entity_name = 'CHARGE'
AND action_name = 'CREATE'
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'CHARGE'
AND action_name = 'CREATE';
</sql>
</changeSet>
<changeSet id="0471_grant_charge_maker_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?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">
<!-- Which renewal cycle of one logical FD this account row represents: 0 for the account the
customer originally opened, 1 for the first renewal, and so on. Together with
fd_account_number (the stable customer-facing number) this turns the chain of per-term
account rows into an explicit "one FD, many cycles" model, so statements and the account
list can present a single FD with numbered terms instead of unrelated accounts.
Defaults to 0 so every pre-existing account reads as its own first cycle. -->
<changeSet id="0472_add_renewal_cycle_no_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="renewal_cycle_no"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="renewal_cycle_no" type="INT" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -1,87 +0,0 @@
<?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">
<changeSet id="0472_add_shareproduct_checker_permission_if_not_exists" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER', 'SHAREPRODUCT', 'CREATE_CHECKER', FALSE
WHERE NOT EXISTS (
SELECT 1 FROM m_permission WHERE code = 'CREATE_SHAREPRODUCT_CHECKER'
);
</sql>
</changeSet>
<changeSet id="0472_enable_product_creation_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="4">
SELECT COUNT(*)
FROM m_permission
WHERE code IN ('CREATE_LOANPRODUCT', 'CREATE_SAVINGSPRODUCT', 'CREATE_CURRENTPRODUCT', 'CREATE_SHAREPRODUCT')
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN ('CREATE_LOANPRODUCT', 'CREATE_SAVINGSPRODUCT', 'CREATE_CURRENTPRODUCT', 'CREATE_SHAREPRODUCT');
</sql>
</changeSet>
<changeSet id="0472_grant_product_creation_maker_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN (
'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER',
'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER',
'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER',
'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN (
'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER',
'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER',
'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER',
'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN (
'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER',
'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER',
'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER',
'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,81 @@
<?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">
<!-- Audit trail of FD renewals, one row per term of one logical FD.
A renewal opens each term as its own deposit instance (its own m_savings_account row, so that
term keeps its own immutable ledger, interest postings and GL entries), while the customer
keeps seeing one FD number. That leaves the renewal EVENT itself unrecorded: how much
interest the previous term earned, what it matured at, how much was carried forward, at what
rate, whether the renewal was automatic or done by a teller, and who did it. Deriving that by
joining account rows loses the intent; this table records it explicitly.
fd_account_number is the stable customer-facing number shared by every term, so one FD's
whole history is one indexed lookup. -->
<changeSet id="0473_create_fd_renewal_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_fd_renewal_history"/>
</not>
</preConditions>
<createTable tableName="m_fd_renewal_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="fd_account_number" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="original_savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="previous_savings_account_id" type="BIGINT"/>
<column name="previous_renewal_id" type="BIGINT"/>
<column name="renewal_no" type="INT">
<constraints nullable="false"/>
</column>
<column name="previous_principal" type="DECIMAL(19,6)"/>
<column name="interest_earned" type="DECIMAL(19,6)"/>
<column name="maturity_amount" type="DECIMAL(19,6)"/>
<column name="renewed_amount" type="DECIMAL(19,6)"/>
<column name="interest_rate" type="DECIMAL(19,6)"/>
<column name="start_date" type="DATE"/>
<column name="maturity_date" type="DATE"/>
<column name="tenure" type="INT"/>
<column name="tenure_frequency_enum" type="SMALLINT"/>
<column name="renewal_type" type="VARCHAR(10)"/>
<column name="renewal_status" type="VARCHAR(20)"/>
<column name="renewed_on" type="DATE"/>
<column name="created_by_id" type="BIGINT"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex indexName="idx_fd_renewal_history_fd_account_number" tableName="m_fd_renewal_history">
<column name="fd_account_number"/>
</createIndex>
<createIndex indexName="idx_fd_renewal_history_savings_account_id" tableName="m_fd_renewal_history">
<column name="savings_account_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="original_savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_original_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
<addForeignKeyConstraint baseColumnNames="savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
<addForeignKeyConstraint baseColumnNames="previous_savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_prev_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?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">
<!-- Explicit lifecycle flag on a product's interest-rate chart.
Which chart prices a deposit was previously inferred purely from its from/end dates, so
retiring a rate version meant remembering to set an end date on it, and a version left
open-ended was treated as valid only up to today. Staff think in terms of "these are the
rates we currently offer", so the chart now says so directly and date ranges stay as the
historical record.
Defaults to true so every existing chart keeps working; the changeset then retires any chart
whose end date has already passed, since those were superseded by definition.
Deliberately on m_interest_rate_chart only, not on the shared InterestRateChartFields
embeddable, which is also embedded into m_savings_account_interest_rate_chart (a per-account
snapshot that has no lifecycle of its own). -->
<changeSet id="0474_add_is_active_to_interest_rate_chart" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_interest_rate_chart" columnName="is_active"/>
</not>
</preConditions>
<addColumn tableName="m_interest_rate_chart">
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0474_retire_expired_interest_rate_charts" author="CBS" runOnChange="false">
<update tableName="m_interest_rate_chart">
<column name="is_active" valueBoolean="false"/>
<where>end_date IS NOT NULL AND end_date &lt; CURRENT_DATE</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -1,72 +0,0 @@
<?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">
<changeSet id="0474_add_fixeddepositproduct_checker_permission_if_not_exists" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER', 'FIXEDDEPOSITPRODUCT', 'CREATE_CHECKER', FALSE
WHERE NOT EXISTS (
SELECT 1 FROM m_permission WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER'
);
</sql>
</changeSet>
<changeSet id="0474_enable_fixed_deposit_product_creation_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="1">
SELECT COUNT(*)
FROM m_permission
WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT'
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT';
</sql>
</changeSet>
<changeSet id="0474_grant_fixed_deposit_product_creation_maker_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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">
<!-- Archives the FULL tenure-by-rate chart of a term that is about to be superseded by an in-place FD
renewal, not just the single effective rate m_fd_renewal_history already records.
renewInPlace() reuses the same account row across renewals, so the account's own interest-rate
chart (m_savings_account_interest_rate_chart / _slab) is overwritten in place at every renewal;
the closing term's full rate card would otherwise be gone with no way to answer "what did the
whole chart look like during term 2", only "what rate applied". One row per slab, linked to the
m_fd_renewal_history row for the term the chart belonged to. -->
<changeSet id="0475_create_fd_renewal_chart_slab_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_fd_renewal_chart_slab_history"/>
</not>
</preConditions>
<createTable tableName="m_fd_renewal_chart_slab_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="fd_renewal_history_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="chart_name" type="VARCHAR(100)"/>
<column name="description" type="VARCHAR(200)"/>
<column name="period_type_enum" type="SMALLINT"/>
<column name="from_period" type="INT"/>
<column name="to_period" type="INT"/>
<column name="amount_range_from" type="DECIMAL(19,6)"/>
<column name="amount_range_to" type="DECIMAL(19,6)"/>
<column name="annual_interest_rate" type="DECIMAL(19,6)"/>
<column name="currency_code" type="VARCHAR(3)"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex indexName="idx_fd_renewal_chart_slab_history_renewal_id" tableName="m_fd_renewal_chart_slab_history">
<column name="fd_renewal_history_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="fd_renewal_history_id"
baseTableName="m_fd_renewal_chart_slab_history"
constraintName="m_fd_renewal_chart_slab_history_renewal_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="CASCADE" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_fd_renewal_history" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -1,77 +0,0 @@
<?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">
<changeSet id="0475_add_permissions_role_checker_if_not_exists" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'authorisation', 'PERMISSIONS_ROLE_CHECKER', 'ROLE', 'PERMISSIONS_CHECKER', FALSE
WHERE NOT EXISTS (
SELECT 1 FROM m_permission WHERE code = 'PERMISSIONS_ROLE_CHECKER'
);
</sql>
</changeSet>
<changeSet id="0475_enable_admin_user_and_role_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="9">
SELECT COUNT(*)
FROM m_permission
WHERE code IN (
'CREATE_USER', 'UPDATE_USER', 'DELETE_USER',
'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE',
'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE'
)
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN (
'CREATE_USER', 'UPDATE_USER', 'DELETE_USER',
'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE',
'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE'
);
</sql>
</changeSet>
<changeSet id="0475_grant_admin_user_and_role_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN (
'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER',
'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER',
'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN (
'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER',
'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER',
'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?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">
<!-- The senior-citizen premium a slab of an FD's own chart carries, alongside its standard rate.
m_savings_account_interest_rate_slab is populated from InterestRateChartSlabFields, a shared
embeddable also used by the product-level chart table (m_interest_rate_slab), which has no
senior-citizen concept at all; only the CBS base interest-rate master does. Adding the column
to the shared embeddable would have forced it onto that product table too, so it is added here
instead, directly on this table only, and populated by application code independently of the
embeddable. Column stays null for every Savings/RD account and for any FD term priced off a
product chart rather than the base-rate master, which is the existing, unaffected behaviour for
both. -->
<changeSet id="0476_add_senior_citizen_rate_to_account_chart_slab" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_account_interest_rate_slab" columnName="senior_citizen_rate"/>
</not>
</preConditions>
<addColumn tableName="m_savings_account_interest_rate_slab">
<column name="senior_citizen_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -1,98 +0,0 @@
<?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">
<changeSet id="0476_create_m_user_pending_request_table" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
CREATE TABLE IF NOT EXISTS m_user_pending_request (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NULL REFERENCES m_appuser(id) ON DELETE SET NULL,
request_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
payload_json TEXT NOT NULL,
original_json TEXT NULL,
password_hash VARCHAR(255) NULL,
maker_id BIGINT NOT NULL REFERENCES m_appuser(id),
submitted_date TIMESTAMPTZ NOT NULL,
checker_id BIGINT NULL REFERENCES m_appuser(id),
checked_date TIMESTAMPTZ NULL,
reject_reason VARCHAR(500) NULL,
pending_lock_key VARCHAR(100) NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_user_pending_req_status ON m_user_pending_request(status);
CREATE INDEX IF NOT EXISTS idx_user_pending_req_user_id ON m_user_pending_request(user_id);
CREATE INDEX IF NOT EXISTS idx_user_pending_req_maker ON m_user_pending_request(maker_id);
</sql>
</changeSet>
<changeSet id="0476_configure_user_and_charge_maker_checker_permissions" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
-- Disable native MC on CREATE_USER and UPDATE_USER to avoid core password sanitization error
UPDATE m_permission
SET can_maker_checker = FALSE
WHERE code IN ('CREATE_USER', 'UPDATE_USER');
-- Enable native MC on UPDATE_CHARGE and DELETE_CHARGE
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN ('UPDATE_CHARGE', 'DELETE_CHARGE');
-- Insert bespoke user maker-checker permissions
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'SUBMIT_CREATE_USER', 'USER', 'SUBMIT_CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_CREATE_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'SUBMIT_UPDATE_USER', 'USER', 'SUBMIT_UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_UPDATE_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'APPROVE_USER', 'USER', 'APPROVE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'APPROVE_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'REJECT_USER', 'USER', 'REJECT', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REJECT_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'READ_USER_PENDING_REQUEST', 'USER', 'READ_PENDING', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USER_PENDING_REQUEST');
-- Grant permissions to Super user
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN (
'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER',
'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
-- Grant permissions to bankadmin
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN (
'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER',
'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,21 @@
<?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">
<!-- Carries the senior-citizen premium into the archived rate-chart history too, matching the column
just added to the live account chart in 0476, so a matured term's true historical premium remains
queryable rather than only ever being looked up against whatever the base rate card currently
publishes. -->
<changeSet id="0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_fd_renewal_chart_slab_history" columnName="senior_citizen_rate"/>
</not>
</preConditions>
<addColumn tableName="m_fd_renewal_chart_slab_history">
<column name="senior_citizen_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,29 @@
<?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">
<!-- Permission for the new FD "Payment" command, mirroring PREMATURECLOSE_FIXEDDEPOSITACCOUNT's
grouping and maker-checker setting exactly, so it slots into the same role-assignment screen. -->
<changeSet id="0478_add_fd_payment_permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT', 'FIXEDDEPOSITACCOUNT', 'PAYMENT', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT');
</sql>
</changeSet>
<!-- The checker half of the pair above. Because that row is can_maker_checker = true, switching the
global maker-checker setting on makes every FD Payment queue for approval, and approving one
requires the _CHECKER permission by name. Without this row that permission cannot be granted to
any role, so queued payments would be unapprovable by anyone but a super user. Every other
maker-checker FD permission (APPROVE, CLOSE, PREMATURECLOSE, POSTINTEREST, ...) has this twin. -->
<changeSet id="0478_add_fd_payment_checker_permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER', 'FIXEDDEPOSITACCOUNT', 'PAYMENT_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER');
</sql>
</changeSet>
</databaseChangeLog>
@@ -28,7 +28,6 @@ import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.codes.exception.CodeNotFoundException;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -114,528 +113,4 @@ public class CommandSourceServiceTest {
Assertions.assertEquals(1001, result.getErrorCode());
Assertions.assertTrue(result.getMessage().contains("Code with name `foo` does not exist"));
}
@Test
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, superUser, false);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(superUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Create() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(100L);
Mockito.when(commandSource.getResourceId()).thenReturn(200L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_GLACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Update_And_Disable() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("UPDATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("UPDATE");
Mockito.when(commandSource.getId()).thenReturn(101L);
Mockito.when(commandSource.getResourceId()).thenReturn(201L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("UPDATE_GLACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Delete() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("DELETE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("DELETE");
Mockito.when(commandSource.getId()).thenReturn(102L);
Mockito.when(commandSource.getResourceId()).thenReturn(202L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("DELETE_GLACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_GLAccount_CheckerApproval_ExecutesSuccessfully() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("UPDATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("UPDATE");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("UPDATE_GLACCOUNT")).thenReturn(true);
Mockito.when(checkerUser.isCheckerSuperUser()).thenReturn(false);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_GLAccount_NonSuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser regularUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(103L);
Mockito.when(commandSource.getResourceId()).thenReturn(203L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_GLACCOUNT")).thenReturn(true);
Mockito.when(regularUser.isCheckerSuperUser()).thenReturn(false);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, regularUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_CBSBaseInterestRate_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CBSBASEINTERESTRATE");
Mockito.when(commandSource.getEntityName()).thenReturn("CBSBASEINTERESTRATE");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(104L);
Mockito.when(commandSource.getResourceId()).thenReturn(204L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CBSBASEINTERESTRATE")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_CBSBaseInterestRate_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CBSBASEINTERESTRATE");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CBSBASEINTERESTRATE")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_Charge_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CHARGE");
Mockito.when(commandSource.getEntityName()).thenReturn("CHARGE");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(105L);
Mockito.when(commandSource.getResourceId()).thenReturn(205L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CHARGE")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_Charge_NonSuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser regularUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CHARGE");
Mockito.when(commandSource.getEntityName()).thenReturn("CHARGE");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(106L);
Mockito.when(commandSource.getResourceId()).thenReturn(206L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CHARGE")).thenReturn(true);
Mockito.when(regularUser.isCheckerSuperUser()).thenReturn(false);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, regularUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_LoanProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_LOANPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("LOANPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(110L);
Mockito.when(commandSource.getResourceId()).thenReturn(210L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_LOANPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_SavingsProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_SAVINGSPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(111L);
Mockito.when(commandSource.getResourceId()).thenReturn(211L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_SAVINGSPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_CurrentProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CURRENTPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("CURRENTPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(112L);
Mockito.when(commandSource.getResourceId()).thenReturn(212L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CURRENTPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_ShareProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_SHAREPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("SHAREPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(113L);
Mockito.when(commandSource.getResourceId()).thenReturn(213L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_SHAREPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_FixedDepositProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("FIXEDDEPOSITPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(114L);
Mockito.when(commandSource.getResourceId()).thenReturn(214L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_FixedDepositProduct_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_Products_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_LOANPRODUCT");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_LOANPRODUCT")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_UserOperations_SuperUserBypassBlocked_ThrowsRollbackException() {
String[] actions = { "CREATE", "UPDATE", "DELETE" };
String[] permissions = { "CREATE_USER", "UPDATE_USER", "DELETE_USER" };
for (int i = 0; i < actions.length; i++) {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getEntityName()).thenReturn("USER");
Mockito.when(commandSource.getActionName()).thenReturn(actions[i]);
Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]);
Mockito.when(commandSource.isSanitized()).thenReturn(false);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
}
@Test
public void testProcessCommand_RoleOperations_SuperUserBypassBlocked_ThrowsRollbackException() {
String[] actions = { "CREATE", "UPDATE", "PERMISSIONS", "DELETE" };
String[] permissions = { "CREATE_ROLE", "UPDATE_ROLE", "PERMISSIONS_ROLE", "DELETE_ROLE" };
for (int i = 0; i < actions.length; i++) {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getEntityName()).thenReturn("ROLE");
Mockito.when(commandSource.getActionName()).thenReturn(actions[i]);
Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]);
Mockito.when(commandSource.isSanitized()).thenReturn(false);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
}
@Test
public void testProcessCommand_UserAndRole_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_USER");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_USER")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
}
@@ -1,60 +0,0 @@
/**
* 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.charge.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.charge.service.ChargeWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateChargeDefinitionCommandHandlerTest {
@Mock
private ChargeWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateChargeDefinitionCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(101L)
.withEntityId(201L)
.build();
when(writePlatformService.createCharge(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).createCharge(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -1,60 +0,0 @@
/**
* 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.loanproduct.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateLoanProductCommandHandlerTest {
@Mock
private LoanProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateLoanProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(101L)
.withEntityId(201L)
.build();
when(writePlatformService.createLoanProduct(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).createLoanProduct(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -1,60 +0,0 @@
/**
* 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.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateCbsBaseInterestRateCommandHandlerTest {
@Mock
private FdBaseInterestRateWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateCbsBaseInterestRateCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(100L)
.withEntityId(200L)
.build();
when(writePlatformService.saveInterestRates(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).saveInterestRates(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -1,60 +0,0 @@
/**
* 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.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateSavingsProductCommandHandlerTest {
@Mock
private SavingsProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateSavingsProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(102L)
.withEntityId(202L)
.build();
when(writePlatformService.create(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).create(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -1,60 +0,0 @@
/**
* 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.handler.current;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateCurrentProductCommandHandlerTest {
@Mock
private CurrentProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateCurrentProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(103L)
.withEntityId(203L)
.build();
when(writePlatformService.create(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).create(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -1,265 +0,0 @@
/**
* 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.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffective;
import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffectiveRepository;
import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateSlabHistoryRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class FdBaseInterestRateReadPlatformServiceImplTest {
@Mock
private FdBaseInterestRateEffectiveRepository effectiveRepository;
@Mock
private FdBaseInterestRateSlabHistoryRepository slabRepository;
@InjectMocks
private FdBaseInterestRateReadPlatformServiceImpl service;
@BeforeEach
void setUp() {
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
}
@AfterEach
void tearDown() {
ThreadLocalContextUtil.setTenant(null);
}
private FdInterestRateSaveRequest.SlabRequest createSlab(int srNo, String desc, int periodType, int from, Integer to, double rate, double seniorRate) {
FdInterestRateSaveRequest.SlabRequest s = new FdInterestRateSaveRequest.SlabRequest();
s.setSrNo(srNo);
s.setDescription(desc);
s.setPeriodTypeEnum(periodType);
s.setFromPeriod(from);
s.setToPeriod(to);
s.setAnnualInterestRate(BigDecimal.valueOf(rate));
s.setSeniorCitizenRate(BigDecimal.valueOf(seniorRate));
return s;
}
@Test
void testValidSingleDepositSlabs() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
request.setVersionLabel("v1.0");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "30 to 89 Days", 0, 30, 89, 5.0, 5.5),
createSlab(3, "90 to 179 Days", 0, 90, 179, 5.5, 6.0),
createSlab(4, "180+ Days", 0, 180, null, 6.0, 6.5)
);
request.setSlabs(slabs);
when(effectiveRepository.findByFdTypeAndIsActiveTrueOrderByEffectiveFromDateDesc("SINGLE"))
.thenReturn(Collections.emptyList());
when(effectiveRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0));
assertDoesNotThrow(() -> service.saveInterestRates(request, 1L));
verify(effectiveRepository).saveAndFlush(any());
}
@Test
void testValidDoubleDepositSlabs() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("DOUBLE");
request.setVersionLabel("v1.0");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 2 Years", 3, 1, 2, 6.5, 7.0),
createSlab(2, "3 to 5 Years", 3, 3, 5, 6.75, 7.25),
createSlab(3, "6 to 10 Years", 3, 6, 10, 7.0, 7.5),
createSlab(4, "11+ Years", 3, 11, null, 7.25, 7.75)
);
request.setSlabs(slabs);
when(effectiveRepository.findByFdTypeAndIsActiveTrueOrderByEffectiveFromDateDesc("DOUBLE"))
.thenReturn(Collections.emptyList());
when(effectiveRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0));
assertDoesNotThrow(() -> service.saveInterestRates(request, 1L));
}
@Test
void testInvalidFirstSlabDoesNotStartAtOne() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "2 to 29 Days", 0, 2, 29, 4.5, 5.0),
createSlab(2, "30+ Days", 0, 30, null, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals(1, ex.getErrors().size());
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].fromPeriod.first.slab.must.start.at.one",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidOverlapRanges() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "20 to 50 Days", 0, 20, 50, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.not.overlap.previous.range",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidGapBetweenRanges() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "35 to 89 Days", 0, 35, 89, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.be.continuous.with.previous.range",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidDuplicateRanges() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "1 to 29 Days duplicate", 0, 1, 29, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.not.overlap.previous.range",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidFromGreaterThanTo() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 20 Days", 0, 1, 20, 4.5, 5.0),
createSlab(2, "21 to 10 Days", 0, 21, 10, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].toPeriod.cannot.be.less.than.fromPeriod",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidBlankToPeriodOnIntermediateSlab() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to null Days", 0, 1, null, 4.5, 5.0),
createSlab(2, "30+ Days", 0, 30, null, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].toPeriod.cannot.be.blank.for.intermediate.slab",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidMixedPeriodTypes() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "30 to 89 Months", 2, 30, 89, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].periodTypeEnum.must.be.consistent.across.all.slabs",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidNegativeInterestRate() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, -1.0, 5.0)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].annualInterestRate.must.be.non.negative",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidNegativeSeniorRate() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, -0.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].seniorCitizenRate.must.be.non.negative",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
}
@@ -1,80 +0,0 @@
/**
* 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.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.useradministration.domain.AppUser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class FdBaseInterestRateWritePlatformServiceImplTest {
@Mock
private PlatformSecurityContext context;
@Mock
private FdBaseInterestRateReadPlatformService readPlatformService;
@Mock
private JsonCommand command;
@Mock
private AppUser appUser;
@InjectMocks
private FdBaseInterestRateWritePlatformServiceImpl writePlatformService;
@Test
void shouldParseJsonAndSaveInterestRates() {
String jsonPayload = "{\"fdType\":\"SINGLE\",\"versionLabel\":\"v1.0\",\"slabs\":[{\"srNo\":1,\"description\":\"1 to 29 Days\",\"periodTypeEnum\":0,\"fromPeriod\":1,\"toPeriod\":29,\"annualInterestRate\":5.0,\"seniorCitizenRate\":5.5}]}";
when(command.json()).thenReturn(jsonPayload);
when(command.commandId()).thenReturn(789L);
when(context.authenticatedUser()).thenReturn(appUser);
when(appUser.getId()).thenReturn(1L);
FdInterestRateEffectiveData savedData = FdInterestRateEffectiveData.builder()
.id(10L)
.fdType("SINGLE")
.versionLabel("v1.0")
.build();
when(readPlatformService.saveInterestRates(any(FdInterestRateSaveRequest.class), eq(1L))).thenReturn(savedData);
CommandProcessingResult result = writePlatformService.saveInterestRates(command);
assertNotNull(result);
assertEquals(789L, result.getCommandId());
assertEquals(10L, result.getResourceId());
verify(readPlatformService).saveInterestRates(any(FdInterestRateSaveRequest.class), eq(1L));
}
}
@@ -1,60 +0,0 @@
/**
* 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.shareproducts.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.shareproducts.service.ShareProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateShareProductCommandHandlerTest {
@Mock
private ShareProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateShareProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(104L)
.withEntityId(204L)
.build();
when(writePlatformService.createShareProduct(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).createShareProduct(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.interestratechart.InterestRateChartS
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -67,6 +68,28 @@ public class InterestRateChart extends AbstractPersistableCustom<Long> {
@OneToMany(mappedBy = "interestRateChart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<InterestRateChartSlab> chartSlabs = new HashSet<>();
/**
* Whether this is a rate version the product currently offers.
* <p>
* Kept as an explicit flag rather than inferred from the from/end dates, so retiring a rate version is a deliberate
* act and the dates stay purely the historical record. New charts start active.
*/
@Column(name = "is_active", nullable = false)
private boolean active = true;
public boolean isActive() {
return this.active;
}
/** Retires this rate version, so it stops being picked for new and renewing deposits. */
public void deactivate() {
this.active = false;
}
public void activate() {
this.active = true;
}
protected InterestRateChart() {
//
}
@@ -123,6 +123,10 @@ public class InterestRateChartFields {
return compare != null && DateUtils.isAfter(getFromDate(), compare);
}
public String getName() {
return this.name;
}
public LocalDate getFromDate() {
return this.fromDate;
}
@@ -177,6 +177,10 @@ public class InterestRateChartSlabFields {
return isGreater;
}
public String description() {
return this.description;
}
public Integer periodType() {
return this.periodType;
}
@@ -43,6 +43,13 @@ public final class DepositAccountInterestRateChartSlabData {
private final BigDecimal annualInterestRate;
private final CurrencyData currency;
/**
* The senior-citizen premium published on the CBS Base Interest Rate master for this tenure band, carried onto the
* account's own chart slab so it survives independently of whatever the master shows today. Null for a slab priced
* off the product's own chart (which has no senior-citizen concept) or for any Savings/RD slab.
*/
private final BigDecimal seniorCitizenRate;
// associations
private Collection<DepositAccountInterestIncentiveData> incentives;
@@ -66,7 +73,7 @@ public final class DepositAccountInterestRateChartSlabData {
public static DepositAccountInterestRateChartSlabData instance(final Long id, final String description, final EnumOptionData periodType,
final Integer fromPeriod, final Integer toPeriod, final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo,
final BigDecimal annualInterestRate, final CurrencyData currency) {
final BigDecimal annualInterestRate, final CurrencyData currency, final BigDecimal seniorCitizenRate) {
final Collection<EnumOptionData> periodTypes = null;
final Collection<EnumOptionData> entityTypeOptions = null;
final Collection<EnumOptionData> attributeNameOptions = null;
@@ -78,7 +85,8 @@ public final class DepositAccountInterestRateChartSlabData {
final Collection<DepositAccountInterestIncentiveData> incentives = null;
return new DepositAccountInterestRateChartSlabData(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, incentives, currency, periodTypes, entityTypeOptions, attributeNameOptions,
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions);
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions,
seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData from(final InterestRateChartSlabData chartSlabData) {
@@ -90,12 +98,14 @@ public final class DepositAccountInterestRateChartSlabData {
fromProdIncentives.add(DepositAccountInterestIncentiveData.from(incentive));
}
}
// the product-level chart this comes from has no senior-citizen concept at all
final BigDecimal seniorCitizenRate = null;
return new DepositAccountInterestRateChartSlabData(id, chartSlabData.description(), chartSlabData.periodType(),
chartSlabData.fromPeriod(), chartSlabData.toPeriod(), chartSlabData.amountRangeFrom(), chartSlabData.amountRangeTo(),
chartSlabData.annualInterestRate(), fromProdIncentives, chartSlabData.currency(), chartSlabData.periodTypes(),
chartSlabData.entityTypeOptions(), chartSlabData.attributeNameOptions(), chartSlabData.conditionTypeOptions(),
chartSlabData.incentiveTypeOptions(), chartSlabData.genderOptions(), chartSlabData.clientTypeOptions(),
chartSlabData.clientClassificationOptions());
chartSlabData.clientClassificationOptions(), seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData withTemplate(final DepositAccountInterestRateChartSlabData chartSlab,
@@ -106,7 +116,7 @@ public final class DepositAccountInterestRateChartSlabData {
return new DepositAccountInterestRateChartSlabData(chartSlab.id, chartSlab.description, chartSlab.periodType, chartSlab.fromPeriod,
chartSlab.toPeriod, chartSlab.amountRangeFrom, chartSlab.amountRangeTo, chartSlab.annualInterestRate, chartSlab.incentives,
chartSlab.currency, periodTypes, entityTypeOptions, attributeNameOptions, conditionTypeOptions, incentiveTypeOptions,
genderOptions, clientTypeOptions, clientClassificationOptions);
genderOptions, clientTypeOptions, clientClassificationOptions, chartSlab.seniorCitizenRate);
}
private DepositAccountInterestRateChartSlabData(final Long id, final String description, final EnumOptionData periodType,
@@ -115,7 +125,8 @@ public final class DepositAccountInterestRateChartSlabData {
final CurrencyData currency, final Collection<EnumOptionData> periodTypes, final Collection<EnumOptionData> entityTypeOptions,
final Collection<EnumOptionData> attributeNameOptions, final Collection<EnumOptionData> conditionTypeOptions,
final Collection<EnumOptionData> incentiveTypeOptions, final Collection<CodeValueData> genderOptions,
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions) {
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions,
final BigDecimal seniorCitizenRate) {
this.id = id;
this.description = description;
this.periodType = periodType;
@@ -125,6 +136,7 @@ public final class DepositAccountInterestRateChartSlabData {
this.amountRangeTo = amountRangeTo;
this.annualInterestRate = annualInterestRate;
this.currency = currency;
this.seniorCitizenRate = seniorCitizenRate;
this.periodTypes = periodTypes;
this.incentives = incentivesData;
this.attributeNameOptions = attributeNameOptions;
@@ -138,6 +138,50 @@ public final class FixedDepositAccountData extends DepositAccountData {
private Boolean isSeniorCitizen;
private java.math.BigDecimal seniorCitizenInterestRate;
// Reinvestment trail: which account this one was reinvested FROM (set on the new account) and
// which account it was reinvested INTO (derived by reverse lookup on the old account), so staff
// can trace a "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure both directions
// instead of the link disappearing once the old account closes.
private Long reinvestedFromAccountId;
private String reinvestedFromAccountNo;
private Long reinvestedIntoAccountId;
private String reinvestedIntoAccountNo;
public void setReinvestmentLinks(final Long reinvestedFromAccountId, final String reinvestedFromAccountNo,
final Long reinvestedIntoAccountId, final String reinvestedIntoAccountNo) {
this.reinvestedFromAccountId = reinvestedFromAccountId;
this.reinvestedFromAccountNo = reinvestedFromAccountNo;
this.reinvestedIntoAccountId = reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = reinvestedIntoAccountNo;
}
// The account number the customer should keep seeing across renewals -- the ORIGINAL account's
// account_no, carried forward onto every account created by reinvesting it. Falls back to this
// account's own accountNo when it has never been reinvested (see DepositAccountReadPlatformServiceImpl).
private String fdAccountNumber;
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
/** Which renewal cycle of that FD this account is: 0 = originally opened, 1 = first renewal, etc. */
private Integer renewalCycleNo;
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
/**
* Every term of this FD, oldest first: one entry per account in the renewal chain, each carrying that term's own
* account number, cycle number, dates, principal and maturity amount. Lets the account view and the deposit
* certificate present "one FD, many terms" without the caller having to walk the chain itself.
*/
private java.util.List<java.util.Map<String, Object>> renewalHistory;
public void setRenewalHistory(final java.util.List<java.util.Map<String, Object>> renewalHistory) {
this.renewalHistory = renewalHistory;
}
// CBS account-level fields (stored per-account in m_savings_account)
private String accountStatus;
private String modeOfOperation;
@@ -329,6 +373,13 @@ public final class FixedDepositAccountData extends DepositAccountData {
this.jointHolders = src.jointHolders;
this.isSeniorCitizen = src.isSeniorCitizen;
this.seniorCitizenInterestRate = src.seniorCitizenInterestRate;
this.reinvestedFromAccountId = src.reinvestedFromAccountId;
this.reinvestedFromAccountNo = src.reinvestedFromAccountNo;
this.reinvestedIntoAccountId = src.reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo;
this.fdAccountNumber = src.fdAccountNumber;
this.renewalCycleNo = src.renewalCycleNo;
this.renewalHistory = src.renewalHistory;
this.subStatus = src.subStatus;
this.reasonForBlock = src.reasonForBlock;
this.lastActiveTransactionDate = src.lastActiveTransactionDate;
@@ -76,6 +76,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// for account close
private final Collection<EnumOptionData> onAccountClosureOptions;
private final Collection<PaymentTypeData> paymentTypeOptions;
private final Collection<EnumOptionData> maturityInstructionOptions;
// import fields
private transient Integer rowIndex;
@@ -364,6 +365,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
this.savingsAccounts = null;
this.onAccountClosureOptions = null;
this.paymentTypeOptions = null;
this.maturityInstructionOptions = null;
this.rowIndex = rowIndex;
this.dateFormat = dateFormat;
this.locale = locale;
@@ -394,6 +396,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<SavingsAccountData> savingsAccountDatas = null;
final Integer recurringFrequency = null;
final EnumOptionData recurringFrequencyType = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(depositAccountData.id, depositAccountData.accountNo, depositAccountData.externalId,
depositAccountData.groupId, depositAccountData.groupName, depositAccountData.clientId, depositAccountData.clientName,
@@ -416,7 +419,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas, expectedFirstDepositOnDate,
totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments,
isCalendarInherited, recurringFrequency, recurringFrequencyType, depositAccountData.withHoldTax,
depositAccountData.taxGroup);
depositAccountData.taxGroup, maturityInstructionOptions);
}
public static RecurringDepositAccountData withInterestChartAndRecurringDetails(final RecurringDepositAccountData account,
@@ -441,7 +444,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, recurringFrequency, recurringFrequencyType,
account.withHoldTax, account.taxGroup);
account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
return result;
}
@@ -463,11 +466,13 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions = null;
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<SavingsAccountData> savingsAccountDatas = account.savingsAccounts;
final Collection<EnumOptionData> maturityInstructionOptions = account.maturityInstructionOptions;
return withTemplateOptions(account, productOptions, fieldOfficerOptions, interestCompoundingPeriodTypeOptions,
interestPostingPeriodTypeOptions, interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions,
lockinPeriodFrequencyTypeOptions, withdrawalFeeTypeOptions, transactions, charges, chargeOptions,
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions);
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
final RecurringDepositAccountData result = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
@@ -486,10 +491,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
template.periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, template.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
// Merge accountTypeOptions and customerSegmentOptions from template
if (template.getAccountTypeOptions() != null) result.setAccountTypeOptions(template.getAccountTypeOptions());
@@ -507,7 +512,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> lockinPeriodFrequencyTypeOptions, final Collection<EnumOptionData> withdrawalFeeTypeOptions,
final Collection<SavingsAccountTransactionData> transactions, final Collection<SavingsAccountChargeData> charges,
final Collection<ChargeData> chargeOptions, final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions,
final Collection<EnumOptionData> periodFrequencyTypeOptions) {
final Collection<EnumOptionData> periodFrequencyTypeOptions, final Collection<SavingsAccountData> savingsAccountDatas,
final Collection<EnumOptionData> maturityInstructionOptions) {
final RecurringDepositAccountData r2 = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
account.clientId, account.clientName, account.depositProductId, account.depositProductName, account.fieldOfficerId,
@@ -524,10 +530,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, maturityInstructionOptions);
r2.copyCbsFieldsFrom(account);
return r2;
}
@@ -609,6 +615,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName,
fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -622,7 +629,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData preClosureDetails(final Long accountId, final BigDecimal maturityAmount,
@@ -700,6 +708,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(accountId, accountNo, externalId, groupId, groupName, clientId, clientName, productId,
productName, fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -713,7 +722,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData withClosureTemplateDetails(final RecurringDepositAccountData account,
@@ -739,7 +749,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
closureResult.copyCbsFieldsFrom(account);
return closureResult;
}
@@ -774,7 +784,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final BigDecimal totalOverdueAmount, final Integer noOfOverdueInstallments, final boolean isMandatoryDeposit,
final boolean allowWithdrawal, final boolean adjustAdvanceTowardsFuturePayments, final boolean isCalendarInherited,
final Integer recurringFrequency, final EnumOptionData recurringFrequencyType, final boolean withHoldTax,
final TaxGroupData taxGroup) {
final TaxGroupData taxGroup, final Collection<EnumOptionData> maturityInstructionOptions) {
super(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName, fieldofficerId, fieldofficerName,
status, timeline, currency, nominalAnnualInterestRate, interestPeriodType, interestPostingPeriodType,
@@ -818,6 +828,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// account close template options
this.onAccountClosureOptions = onAccountClosureOptions;
this.paymentTypeOptions = paymentTypeOptions;
this.maturityInstructionOptions = maturityInstructionOptions;
}
@Override
@@ -28,12 +28,16 @@ import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlab;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculation;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculationFactory;
@@ -156,4 +160,86 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom<L
public boolean isPrimaryGroupingByAmount() {
return this.chartFields.isPrimaryGroupingByAmount();
}
/**
* Re-snapshots this account's chart from the product's current chart, in place.
* <p>
* Used when an FD renews on the same account: the new term must be priced off whatever the product offers on the
* renewal date, not the rates frozen when the account was first opened. This has to mutate the existing row rather
* than swap in a new chart, because the account-to-chart mapping is one-to-one on the chart's savings_account_id, so
* a second row for the same account would break loading it.
*/
public void refreshFrom(final InterestRateChart productChart) {
this.chartFields = productChart.chartFields();
// orphanRemoval on chartSlabs deletes the superseded slab rows.
setOfChartSlabs().clear();
for (final InterestRateChartSlab productSlab : productChart.setOfChartSlabs()) {
this.chartSlabs.add(DepositAccountInterestRateChartSlabs.from(productSlab, this));
}
}
/**
* A slab's rate-by-tenure fields plus its senior-citizen premium, travelling together.
* <p>
* The premium isn't part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level chart slab table, which has no CBS senior-citizen concept -- so it rides alongside as a plain pair
* rather than being added to the shared type.
*/
public static final class SlabSnapshot {
private final InterestRateChartSlabFields fields;
private final BigDecimal seniorCitizenRate;
public SlabSnapshot(final InterestRateChartSlabFields fields, final BigDecimal seniorCitizenRate) {
this.fields = fields;
this.seniorCitizenRate = seniorCitizenRate;
}
public InterestRateChartSlabFields getFields() {
return this.fields;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
}
/**
* This chart's slabs as they stand right now, each paired with its senior-citizen premium. Read before
* {@link #refreshFromSlabSnapshots} overwrites them, so a renewing FD can archive the term that is closing rather than
* losing its rate card the moment the new term's rates replace it.
*/
public List<SlabSnapshot> currentSlabSnapshots() {
final List<SlabSnapshot> snapshots = new ArrayList<>();
for (final DepositAccountInterestRateChartSlabs slab : setOfChartSlabs()) {
snapshots.add(new SlabSnapshot(slab.slabFields(), slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* Re-snapshots this account's chart from an explicit set of slabs (with their senior-citizen premiums), in place.
* <p>
* Used when the rates come from the CBS base interest-rate master rather than from the product's own chart -- that
* master is the rate card staff maintain and see on screen, so a renewing deposit priced off anything else would
* disagree with what the bank publishes. Mutates the existing row for the same reason
* {@link #refreshFrom(InterestRateChart)} does: the account-to-chart mapping is one-to-one on the chart's
* savings_account_id, so a second row for the same account would break loading it.
* <p>
* Does nothing when handed no slabs, leaving the account on the chart it already has, so a missing or empty rate
* version can never leave a deposit with no rates at all.
*/
public void refreshFromSlabSnapshots(final InterestRateChartFields chartFields, final Collection<SlabSnapshot> snapshots) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
if (chartFields != null) {
this.chartFields = chartFields;
}
setOfChartSlabs().clear();
for (final SlabSnapshot snapshot : snapshots) {
this.chartSlabs
.add(DepositAccountInterestRateChartSlabs.fromFields(snapshot.getFields(), this, snapshot.getSeniorCitizenRate()));
}
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -26,6 +27,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -47,6 +49,16 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
@OneToMany(mappedBy = "depositAccountInterestRateChartSlabs", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<DepositAccountInterestIncentives> interestIncentives = new HashSet<>();
/**
* The senior-citizen premium for this slab's tenure band, as published on the CBS Base Interest Rates screen at the
* time this chart was set. Not part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level {@code m_interest_rate_slab} table, which has no CBS senior-citizen concept; kept as a column of its
* own here instead, on the account-level table only. Null for a slab copied from a product chart (which never carries
* one) or for a slab that predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
protected DepositAccountInterestRateChartSlabs() {
//
}
@@ -59,6 +71,10 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
this.interestIncentives = interestIncentives;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
public void setDepositAccountInterestRateChart(DepositAccountInterestRateChart depositAccountInterestRateChart) {
this.depositAccountInterestRateChart = depositAccountInterestRateChart;
}
@@ -71,6 +87,19 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
return this.slabFields;
}
/**
* Builds a slab straight from its field values, for a chart taken from a source that is not a product
* {@link InterestRateChartSlab} -- specifically the CBS base interest-rate master, which is the bank's published rate
* card and carries no incentive rules.
*/
public static DepositAccountInterestRateChartSlabs fromFields(final InterestRateChartSlabFields slabFields,
final DepositAccountInterestRateChart depositAccountInterestRateChart, final BigDecimal seniorCitizenRate) {
final DepositAccountInterestRateChartSlabs slab = new DepositAccountInterestRateChartSlabs(slabFields,
depositAccountInterestRateChart, new HashSet<>());
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
public static DepositAccountInterestRateChartSlabs from(InterestRateChartSlab interestRateChartSlab,
DepositAccountInterestRateChart depositAccountInterestRateChart) {
InterestRateChartSlabFields slabFields = interestRateChartSlab.slabFields();
@@ -89,6 +89,49 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
@Column(name = "transfer_to_savings_account_id")
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;
}
// The account_no of the FIRST account in a renewal chain, carried forward onto every account
// created by reinvesting it. Null on an account that has never been reinvested -- callers should
// fall back to that account's own account_no in that case (see FixedDepositAccount.reInvest()).
@Column(name = "fd_account_number", length = 20)
private String fdAccountNumber;
public String getFdAccountNumber() {
return this.fdAccountNumber;
}
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
// Which renewal cycle of one logical FD this account represents: 0 = originally opened account,
// 1 = first renewal, and so on. Lets the UI present "one FD, many terms" over what is physically
// a chain of per-term account rows.
@Column(name = "renewal_cycle_no")
private Integer renewalCycleNo;
public Integer getRenewalCycleNo() {
return this.renewalCycleNo == null ? 0 : this.renewalCycleNo;
}
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
protected DepositAccountTermAndPreClosure() {
}
@@ -297,6 +340,19 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
this.onAccountClosureType = onClosureType.getValue();
}
/**
* Copies the standing instructions -- maturity instruction, its payout target and the interest-transfer flag -- from
* the term being renewed.
* <p>
* These describe what the customer asked the deposit to do at every maturity, not a one-off choice for a single term,
* so a renewed deposit that lost them would mature once more and then sit idle instead of rolling over again.
*/
public void carryForwardStandingInstructionsFrom(final DepositAccountTermAndPreClosure previousTerm) {
this.onAccountClosureType = previousTerm.onAccountClosureType;
this.transferToSavingsAccountId = previousTerm.transferToSavingsAccountId;
this.transferInterestToLinkedAccount = previousTerm.transferInterestToLinkedAccount;
}
public boolean isReinvestOnClosure() {
return DepositAccountOnClosureType.fromInt(this.onAccountClosureType).isReinvest();
}
@@ -317,6 +373,9 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
final LocalDate expectedFirstDepositOnDate = null;
final Boolean transferInterestToLinkedAccount = false;
// Left blank here on purpose: this method is shared with Recurring Deposit renewal, so carrying
// standing instructions forward is done by the caller that wants it (see
// FixedDepositAccount#reInvest) rather than changed for both product types at once.
final DepositAccountOnClosureType accountOnClosureType = null;
final Long transferToSavingsId = null;
return DepositAccountTermAndPreClosure.createNew(preClosureDetail, depositTermDetail, account, actualDepositAmount, maturityAmount,
@@ -324,6 +324,42 @@ public class FixedDepositProduct extends SavingsProduct {
return applicableChart;
}
/**
* The rate chart the product currently offers for a deposit priced on the given date: a chart flagged active, that has
* come into force by that date and has not been superseded. Where more than one qualifies, the most recently effective
* wins.
* <p>
* Differs from {@link #applicableChart} in three ways that matter when pricing a renewal. That method ignores the
* active flag entirely; it treats an open-ended chart (no end date) as valid only up to today's business date, so a
* deposit renewing on a future maturity date finds no chart at all; and it returns the first match from an unordered
* set, so two charts covering the same date resolve arbitrarily.
* <p>
* Returns null when the product offers no active chart in force on that date.
*/
public InterestRateChart activeChart(final LocalDate target) {
if (this.charts == null || target == null) {
return null;
}
InterestRateChart activeChart = null;
for (final InterestRateChart chart : this.charts) {
if (!chart.isActive()) {
continue; // retired rate version
}
final LocalDate fromDate = chart.getFromDate();
final LocalDate endDate = chart.getEndDate();
if (fromDate == null || fromDate.isAfter(target)) {
continue; // not yet in force
}
if (endDate != null && endDate.isBefore(target)) {
continue; // already superseded
}
if (activeChart == null || fromDate.isAfter(activeChart.getFromDate())) {
activeChart = chart;
}
}
return activeChart;
}
public DepositProductTermAndPreClosure depositProductTermAndPreClosure() {
return this.productTermAndPreClosure;
}
@@ -605,19 +605,29 @@ public final class ProductPendingEditFieldMapper {
// ---- 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) {
final List<ProductPendingEditGlMapping> mappings = new java.util.ArrayList<>();
boolean any = false;
any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL,
"paymentTypeId", mappings);
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", mappings);
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD, mappings);
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId",
INCOME_ACCOUNT_GL_FIELD, mappings);
any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId",
mappings);
INCOME_ACCOUNT_GL_FIELD, mappings);
return any ? mappings : null;
}
private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName,
final String mappingType, final String idFieldName, final List<ProductPendingEditGlMapping> out) {
final String mappingType, final String idFieldName, final String glFieldName,
final List<ProductPendingEditGlMapping> out) {
if (!command.hasParameter(paramName)) {
return false;
}
@@ -625,8 +635,13 @@ public final class ProductPendingEditFieldMapper {
if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
final JsonObject o = el.getAsJsonObject();
final Long idFieldValue = o.has(idFieldName) ? o.get(idFieldName).getAsLong() : null;
final Long glAccountId = o.has("glAccountId") ? o.get("glAccountId").getAsLong() : null;
final Long idFieldValue = longOrNull(o, idFieldName);
final Long glAccountId = longOrNull(o, glFieldName);
// A mapping is only meaningful with both halves present; keeping a half-filled row
// would fail the satellite table's NOT NULL constraint and abort the whole edit.
if (idFieldValue == null || glAccountId == null) {
continue;
}
final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null;
final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null;
out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId));
@@ -635,15 +650,22 @@ public final class ProductPendingEditFieldMapper {
return true;
}
private static Long longOrNull(final JsonObject o, final String fieldName) {
final com.google.gson.JsonElement el = o.get(fieldName);
return el == null || el.isJsonNull() ? null : el.getAsLong();
}
public static void writeGlArrayMappingsToJson(final JsonObject root, final List<ProductPendingEditGlMapping> mappings) {
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings",
"paymentTypeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId");
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD);
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId",
INCOME_ACCOUNT_GL_FIELD);
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId",
INCOME_ACCOUNT_GL_FIELD);
}
private static void writeGlArrayGroup(final JsonObject root, final List<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();
for (final ProductPendingEditGlMapping mapping : mappings) {
if (!type.equals(mapping.getMappingType())) {
@@ -652,7 +674,7 @@ public final class ProductPendingEditFieldMapper {
final JsonObject entry = new JsonObject();
final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId();
entry.addProperty(idFieldName, idFieldValue);
entry.addProperty("glAccountId", mapping.getGlAccountId());
entry.addProperty(glFieldName, mapping.getGlAccountId());
array.add(entry);
}
root.add(paramName, array);
@@ -828,6 +828,28 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return savingsTransaction;
}
/**
* The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists.
* <p>
* Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening
* balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the
* first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match
* would leave the opening balance at zero on any pivot date that happens to carry no transaction.
* <p>
* Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final
* match is the latest one.
*/
protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) {
SavingsAccountTransaction candidate = null;
for (final SavingsAccountTransaction transaction : getTransactions()) {
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
&& DateUtils.isBefore(transaction.getTransactionDate(), date)) {
candidate = transaction;
}
}
return candidate;
}
protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) {
SavingsAccountTransaction savingsTransaction = null;
List<SavingsAccountTransaction> trans = getSavingsAccountTransactionsWithPivotConfig();
@@ -921,13 +943,23 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
if (backdatedTxnsAllowedTill) {
transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate);
} else {
transaction = findLastTransaction(startInterestCalculationDate);
// Strictly before the pivot, not on it: anything dated on the pivot date belongs to the
// first posting period and is applied there, so seeding from it would apply it twice --
// and an exact-date match would leave this at zero whenever the pivot date carries no
// transaction of its own, silently accruing the whole term on a zero balance.
transaction = findLastTransactionBefore(startInterestCalculationDate);
}
if (transaction == null) {
periodStartingBalance = Money.zero(this.currency);
} else {
} else if (backdatedTxnsAllowedTill) {
periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate());
} else {
// summary.runningBalanceOnPivotDate is @Transient and only ever populated by the
// assembler on the backdated-transactions path, so reading it here returned zero and
// silently wiped the opening balance. The balance carried by the last transaction
// before the start date is the same figure, and is always present.
periodStartingBalance = transaction.getRunningBalance(this.currency);
}
} else {
periodStartingBalance = Money.zero(this.currency);
@@ -3140,7 +3172,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
}
}
private LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) {
protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) {
LocalDate lockedInUntilLocalDate = null;
final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
@@ -82,6 +82,11 @@ public class SavingsAccountRepositoryWrapper {
return account;
}
@Transactional(readOnly = true)
public boolean existsWithDepositAccountType(final Long savingsId, final DepositAccountType depositAccountType) {
return this.repository.findByIdAndDepositAccountType(savingsId, depositAccountType.getValue()) != null;
}
@Transactional(readOnly = true)
public List<SavingsAccount> findSavingAccountByClientId(@Param("clientId") Long clientId) {
List<SavingsAccount> accounts = this.repository.findSavingAccountByClientId(clientId);
@@ -61,6 +61,8 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult prematureCloseRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult processFdPayment(Long savingsId, JsonCommand command);
SavingsAccountTransaction initiateSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);
SavingsAccountTransaction withdrawSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);
@@ -172,7 +172,7 @@ public class FixedDepositTest extends IntegrationTest {
moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class);
moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP));
fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl(
new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper());
new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper());
try {
HashMap h = fixedDepositAccountInterestCalculationServiceImpl
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper()));
@@ -198,7 +198,7 @@ public class FixedDepositTest extends IntegrationTest {
moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class);
moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP));
fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl(
new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper());
new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper());
try {
HashMap h = fixedDepositAccountInterestCalculationServiceImpl
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper()));
@@ -224,7 +224,7 @@ public class FixedDepositTest extends IntegrationTest {
moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class);
moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP));
fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl(
new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper());
new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper());
BigDecimal expectedResult = new BigDecimal("10506.250000");
BigDecimal actualResult = new BigDecimal(fixedDepositAccountInterestCalculationServiceImpl
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper())).get("maturityAmount").toString());