existing account saving and current add or remove nominee and digital & account features and also FE & RD nominee add or remove

This commit is contained in:
keshav898966
2026-07-10 18:40:16 +05:30
parent 9980310c9e
commit b064ee6307
35 changed files with 3439 additions and 23 deletions
@@ -0,0 +1,54 @@
/**
* 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.currentaccount.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/**
* Read-side view of a single pending current account Nominee/Digital-Feature edit
* (m_current_account_pending_edit), for the Approvals -> Pending Current Account Edits screen.
*/
@Getter
@Setter
public class CurrentAccountPendingEditData implements Serializable {
private Long id;
private Long currentAccountId;
private String accountNo;
private Long makerId;
private String makerUsername;
private Long checkerId;
private String checkerUsername;
private LocalDateTime createdOn;
private LocalDateTime checkedOn;
private String status;
private String rejectReason;
private BigDecimal holdAmount;
private boolean facilityConfigSubmitted;
private boolean nomineeSubmitted;
private Map<String, Object> proposedFacilityConfig;
private Map<String, Object> proposedNominee;
public CurrentAccountPendingEditData() {}
}
@@ -0,0 +1,55 @@
/**
* 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.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/**
* Read-side view of a single pending savings account Nominee/Digital-Feature edit
* (m_savings_account_pending_edit), for the Approvals -> Pending Savings Account Edits screen.
*/
@Getter
@Setter
public class SavingsAccountPendingEditData implements Serializable {
private Long id;
private Long savingsAccountId;
private String accountNo;
private Long makerId;
private String makerUsername;
private Long checkerId;
private String checkerUsername;
private LocalDateTime createdOn;
private LocalDateTime checkedOn;
private String status;
private String rejectReason;
private BigDecimal holdAmount;
private boolean facilityConfigSubmitted;
private boolean nomineeSubmitted;
private Map<String, Object> proposedFacilityConfig;
private Map<String, Object> proposedNominee;
private String depositAccountType;
public SavingsAccountPendingEditData() {}
}
@@ -99,4 +99,31 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
cfg.currentAccount = account;
return cfg;
}
/**
* Partial update: only overwrites fields present (non-null) in the given JSON, leaving all others untouched.
* Mirrors {@code SavingsAccountFacilityConfig.updateFrom(JsonObject)}. Used by the Nominee/Digital-Feature
* edit-approval flow, which only ever submits the toggles the maker actually touched.
*/
public void updateFrom(com.google.gson.JsonObject config) {
if (config == null) return;
if (config.has("atmFacility")) setAtmFacility(config.get("atmFacility").getAsBoolean());
if (config.has("atmWithdrawalFacility")) setAtmWithdrawalFacility(config.get("atmWithdrawalFacility").getAsBoolean());
if (config.has("upiFacility")) setUpiFacility(config.get("upiFacility").getAsBoolean());
if (config.has("posTransactionsFacility")) setPosTransactionsFacility(config.get("posTransactionsFacility").getAsBoolean());
if (config.has("chequeBook")) setChequeBook(config.get("chequeBook").getAsBoolean());
if (config.has("impsFacility")) setImpsFacility(config.get("impsFacility").getAsBoolean());
if (config.has("smsAlerts")) setSmsAlerts(config.get("smsAlerts").getAsBoolean());
if (config.has("bbpsFacility")) setBbpsFacility(config.get("bbpsFacility").getAsBoolean());
if (config.has("insuranceCover")) setInsuranceCover(config.get("insuranceCover").getAsBoolean());
if (config.has("bepgFacility")) setBepgFacility(config.get("bepgFacility").getAsBoolean());
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
if (config.has("mobileBanking")) setMobileBanking(config.get("mobileBanking").getAsBoolean());
}
}
@@ -0,0 +1,35 @@
/**
* 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.currentaccount.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* This repository did not previously exist - {@link CurrentAccountFacilityConfig} rows were only ever persisted via
* cascade from {@link CurrentAccountRepository} (see {@code CurrentAccount#facilityConfig}, {@code cascade = ALL}).
* Added so the Nominee/Digital-Feature edit-approval flow can save the config row directly, mirroring
* {@code SavingsAccountFacilityConfigRepository}.
*/
@Repository
public interface CurrentAccountFacilityConfigRepository extends JpaRepository<CurrentAccountFacilityConfig, Long> {
Optional<CurrentAccountFacilityConfig> findByCurrentAccountId(Long currentAccountId);
}
@@ -0,0 +1,596 @@
/**
* 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.currentaccount.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Holds a pending edit request for an ACTIVE current account's Nominee + Digital & Account Features.
* Presence of a row here with {@code status = PENDING} (enforced via the unique {@link #pendingLockKey}) is itself
* the lock - nothing is applied to the real {@code m_current_account_facility_config} / {@code m_client_nominee}
* rows until a checker approves (see CurrentAccountEditWritePlatformServiceJpaRepositoryImpl). Mirrors
* {@code SavingsAccountPendingEdit} but additionally carries a full point-in-time "wide snapshot" of every business
* column on {@link CurrentAccount} at submission time, for audit/UI display purposes only (not used to drive
* approve/reject logic, which reads the live account).
*/
@Entity
@Table(name = "m_current_account_pending_edit")
@Getter
@Setter
public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String NOMINEE_OP_ADD = "ADD";
public static final String NOMINEE_OP_UPDATE = "UPDATE";
public static final String NOMINEE_OP_DELETE = "DELETE";
@ManyToOne
@JoinColumn(name = "current_account_id", nullable = false)
private CurrentAccount currentAccount;
@ManyToOne
@JoinColumn(name = "createdby_id", nullable = false)
private AppUser maker;
@ManyToOne
@JoinColumn(name = "lastmodifiedby_id")
private AppUser checker;
@Column(name = "created_date", nullable = false)
private LocalDateTime createdOn;
@Column(name = "lastmodified_date")
private LocalDateTime checkedOn;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
/** "CURRENT_ACCOUNT_EDIT:<currentAccountId>" while PENDING, NULL otherwise - the real DB-level lock. */
@Column(name = "pending_lock_key", length = 40)
private String pendingLockKey;
/** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */
@Column(name = "command_source_id")
private Long commandSourceId;
@Column(name = "hold_transaction_id")
private Long holdTransactionId;
@Column(name = "hold_amount", precision = 19, scale = 6)
private BigDecimal holdAmount;
// ── Digital & Account Features (same names as m_current_account_facility_config) ──
@Column(name = "facility_config_submitted", nullable = false)
private boolean facilityConfigSubmitted = false;
@Column(name = "atm_facility")
private Boolean atmFacility;
@Column(name = "atm_withdrawal_facility")
private Boolean atmWithdrawalFacility;
@Column(name = "upi_facility")
private Boolean upiFacility;
@Column(name = "pos_transactions_facility")
private Boolean posTransactionsFacility;
@Column(name = "cheque_book")
private Boolean chequeBook;
@Column(name = "imps_facility")
private Boolean impsFacility;
@Column(name = "sms_alerts")
private Boolean smsAlerts;
@Column(name = "bbps_facility")
private Boolean bbpsFacility;
@Column(name = "insurance_cover")
private Boolean insuranceCover;
@Column(name = "bepg_facility")
private Boolean bepgFacility;
@Column(name = "neft_facility")
private Boolean neftFacility;
@Column(name = "rtgs_facility")
private Boolean rtgsFacility;
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc;
@Column(name = "virtual_debit_card")
private Boolean virtualDebitCard;
@Column(name = "internet_banking")
private Boolean internetBanking;
@Column(name = "mobile_banking")
private Boolean mobileBanking;
// ── Nominee change - one per request (add / update / delete) ──
@Column(name = "nominee_submitted", nullable = false)
private boolean nomineeSubmitted = false;
@Column(name = "nominee_operation", length = 10)
private String nomineeOperation;
@Column(name = "nominee_id")
private Long nomineeId;
@Column(name = "nominee_client_id")
private Long nomineeClientId;
@Column(name = "relationship_cv_id")
private Long relationshipCvId;
@Column(name = "percentage_share", precision = 5, scale = 2)
private BigDecimal percentageShare;
@Column(name = "nominee_is_active")
private Boolean nomineeIsActive;
@Column(name = "nominee_is_minor")
private Boolean nomineeIsMinor;
@Column(name = "guardian_client_id")
private Long guardianClientId;
@Column(name = "guardian_relationship_cv_id")
private Long guardianRelationshipCvId;
@Column(name = "effective_date")
private LocalDate effectiveDate;
@Column(name = "nominee_remarks", length = 500)
private String nomineeRemarks;
// ── Wide point-in-time snapshot of every business column on CurrentAccount at submission time ──
// (audit/UI display only - approve()/reject() logic never reads these, it reads the live account)
@Column(name = "account_no", length = 20)
private String snapshotAccountNo;
@Column(name = "external_id", length = 100)
private String snapshotExternalId;
@Column(name = "client_id")
private Long snapshotClientId;
@Column(name = "product_id")
private Long snapshotProductId;
@Column(name = "office_id")
private Long snapshotOfficeId;
@Column(name = "staff_id")
private Long snapshotStaffId;
@Column(name = "status_enum")
private Integer snapshotStatusEnum;
@Column(name = "sub_status_enum")
private Integer snapshotSubStatusEnum;
@Column(name = "account_type_enum")
private Integer snapshotAccountTypeEnum;
@Column(name = "submittedon_date")
private LocalDate snapshotSubmittedOnDate;
@Column(name = "approvedon_date")
private LocalDate snapshotApprovedOnDate;
@Column(name = "activatedon_date")
private LocalDate snapshotActivatedOnDate;
@Column(name = "rejectedon_date")
private LocalDate snapshotRejectedOnDate;
@Column(name = "withdrawnon_date")
private LocalDate snapshotWithdrawnOnDate;
@Column(name = "closedon_date")
private LocalDate snapshotClosedOnDate;
@Column(name = "submittedon_userid")
private Long snapshotSubmittedOnUserId;
@Column(name = "approvedon_userid")
private Long snapshotApprovedOnUserId;
@Column(name = "activatedon_userid")
private Long snapshotActivatedOnUserId;
@Column(name = "rejectedon_userid")
private Long snapshotRejectedOnUserId;
@Column(name = "withdrawnon_userid")
private Long snapshotWithdrawnOnUserId;
@Column(name = "closedon_userid")
private Long snapshotClosedOnUserId;
@Column(name = "currency_code", length = 3)
private String snapshotCurrencyCode;
@Column(name = "currency_digits")
private Integer snapshotCurrencyDigits;
@Column(name = "currency_multiplesof")
private Integer snapshotCurrencyMultiplesOf;
@Column(name = "nominal_annual_interest_rate", precision = 19, scale = 6)
private BigDecimal snapshotNominalAnnualInterestRate;
@Column(name = "interest_compounding_period_enum")
private Integer snapshotInterestCompoundingPeriodEnum;
@Column(name = "interest_posting_period_enum")
private Integer snapshotInterestPostingPeriodEnum;
@Column(name = "interest_calculation_type_enum")
private Integer snapshotInterestCalculationTypeEnum;
@Column(name = "interest_calculation_days_in_year_type_enum")
private Integer snapshotInterestCalculationDaysInYearTypeEnum;
@Column(name = "min_balance_for_interest_calculation", precision = 19, scale = 6)
private BigDecimal snapshotMinBalanceForInterestCalculation;
@Column(name = "start_interest_calculation_date")
private LocalDate snapshotStartInterestCalculationDate;
@Column(name = "accrued_till_date")
private LocalDate snapshotAccruedTillDate;
@Column(name = "last_interest_calculation_date")
private LocalDate snapshotLastInterestCalculationDate;
@Column(name = "last_closed_business_date")
private LocalDate snapshotLastClosedBusinessDate;
@Column(name = "withhold_tax")
private Boolean snapshotWithholdTax;
@Column(name = "min_required_opening_balance", precision = 19, scale = 6)
private BigDecimal snapshotMinRequiredOpeningBalance;
@Column(name = "lockin_period_frequency")
private Integer snapshotLockinPeriodFrequency;
@Column(name = "lockin_period_frequency_enum")
private Integer snapshotLockinPeriodFrequencyEnum;
@Column(name = "lockedin_until_date_derived")
private LocalDate snapshotLockedInUntilDate;
@Column(name = "enforce_min_required_balance")
private Boolean snapshotEnforceMinRequiredBalance;
@Column(name = "min_required_balance", precision = 19, scale = 6)
private BigDecimal snapshotMinRequiredBalance;
@Column(name = "allow_overdraft")
private Boolean snapshotAllowOverdraft;
@Column(name = "overdraft_limit", precision = 19, scale = 6)
private BigDecimal snapshotOverdraftLimit;
@Column(name = "nominal_annual_interest_rate_overdraft", precision = 19, scale = 6)
private BigDecimal snapshotNominalAnnualInterestRateOverdraft;
@Column(name = "min_overdraft_for_interest_calculation", precision = 19, scale = 6)
private BigDecimal snapshotMinOverdraftForInterestCalculation;
@Column(name = "is_lien_allowed")
private Boolean snapshotLienAllowed;
@Column(name = "max_allowed_lien_limit", precision = 19, scale = 6)
private BigDecimal snapshotMaxAllowedLienLimit;
@Column(name = "on_hold_funds_derived", precision = 19, scale = 6)
private BigDecimal snapshotOnHoldFundsDerived;
@Column(name = "account_balance_derived", precision = 19, scale = 6)
private BigDecimal snapshotAccountBalanceDerived;
@Column(name = "total_deposits_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalDepositsDerived;
@Column(name = "total_withdrawals_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalWithdrawalsDerived;
@Column(name = "total_interest_earned_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalInterestEarnedDerived;
@Column(name = "total_interest_posted_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalInterestPostedDerived;
@Column(name = "total_overdraft_interest_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalOverdraftInterestDerived;
@Column(name = "total_withhold_tax_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalWithholdTaxDerived;
@Column(name = "total_savings_amount_on_hold", precision = 19, scale = 6)
private BigDecimal snapshotTotalSavingsAmountOnHold;
@Column(name = "withdrawal_fee_for_transfer")
private Boolean snapshotWithdrawalFeeForTransfer;
@Column(name = "reason_for_block", length = 500)
private String snapshotReasonForBlock;
@Column(name = "msme")
private Boolean snapshotMsme;
@Column(name = "auto_debit_facility")
private Boolean snapshotAutoDebitFacility;
@Column(name = "balance_swap_option")
private Boolean snapshotBalanceSwapOption;
@Column(name = "establishment_date")
private LocalDate snapshotEstablishmentDate;
@Column(name = "start_operation_date")
private LocalDate snapshotStartOperationDate;
@Column(name = "is_non_individual_client")
private Boolean snapshotNonIndividualClient;
@Column(name = "signatory_rights", length = 100)
private String snapshotSignatoryRights;
@Column(name = "cbs_account_type", length = 100)
private String snapshotCbsAccountType;
@Column(name = "customer_segment", length = 100)
private String snapshotCustomerSegment;
@Column(name = "account_status", length = 50)
private String snapshotAccountStatus;
@Column(name = "mode_of_operation", length = 50)
private String snapshotModeOfOperation;
@Column(name = "special_category", length = 100)
private String snapshotSpecialCategory;
@Column(name = "introducer_client_id")
private Long snapshotIntroducerClientId;
@Column(name = "introducer_name", length = 200)
private String snapshotIntroducerName;
@Column(name = "introducer_cif_number", length = 50)
private String snapshotIntroducerCifNumber;
@Column(name = "nominee_data", columnDefinition = "TEXT")
private String snapshotNomineeData;
@Column(name = "interest_calculate_selector")
private Integer snapshotInterestCalculateSelector;
@Column(name = "average_period", length = 20)
private String snapshotAveragePeriod;
@Column(name = "auto_post")
private Boolean snapshotAutoPost;
@Column(name = "frequency_fix_day")
private Integer snapshotFrequencyFixDay;
@Column(name = "enable_lockin_period")
private Boolean snapshotEnableLockinPeriod;
@Column(name = "balance_frequency", length = 50)
private String snapshotBalanceFrequency;
@Column(name = "avg_balance_calc_period", length = 10)
private String snapshotAvgBalanceCalcPeriod;
@Column(name = "account_closed_zero_balance_in_day_closing")
private Boolean snapshotAccountClosedZeroBalanceInDayClosing;
@Column(name = "valid_for_period", length = 10)
private String snapshotValidForPeriod;
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private Boolean snapshotOdAccountClosedZeroBalanceInDayClosing;
@Column(name = "is_linking_enable")
private Boolean snapshotShareLinkingEnabled;
@Column(name = "terminate_true")
private Boolean snapshotTerminateTrue;
protected CurrentAccountPendingEdit() {}
public CurrentAccountPendingEdit(final CurrentAccount currentAccount, final AppUser maker) {
this.currentAccount = currentAccount;
this.maker = maker;
this.createdOn = LocalDateTime.now();
this.status = STATUS_PENDING;
this.pendingLockKey = lockKeyFor(currentAccount.getId());
}
public static String lockKeyFor(final Long currentAccountId) {
return "CURRENT_ACCOUNT_EDIT:" + currentAccountId;
}
/**
* Builds a new pending-edit row and copies every business column off the live {@code account} onto this
* request's wide snapshot fields, for audit/UI display purposes only.
*/
public static CurrentAccountPendingEdit snapshotOf(final CurrentAccount account, final AppUser maker) {
final CurrentAccountPendingEdit p = new CurrentAccountPendingEdit(account, maker);
p.snapshotAccountNo = account.getAccountNo();
p.snapshotExternalId = account.getExternalId();
p.snapshotClientId = account.getClient() != null ? account.getClient().getId() : null;
p.snapshotProductId = account.getProductId();
p.snapshotOfficeId = account.getOffice() != null ? account.getOffice().getId() : null;
p.snapshotStaffId = account.getStaff() != null ? account.getStaff().getId() : null;
p.snapshotStatusEnum = account.getStatusEnum();
p.snapshotSubStatusEnum = account.getSubStatusEnum();
p.snapshotAccountTypeEnum = account.getAccountTypeEnum();
p.snapshotSubmittedOnDate = account.getSubmittedOnDate();
p.snapshotApprovedOnDate = account.getApprovedOnDate();
p.snapshotActivatedOnDate = account.getActivatedOnDate();
p.snapshotRejectedOnDate = account.getRejectedOnDate();
p.snapshotWithdrawnOnDate = account.getWithdrawnOnDate();
p.snapshotClosedOnDate = account.getClosedOnDate();
p.snapshotSubmittedOnUserId = account.getSubmittedOnByUser() != null ? account.getSubmittedOnByUser().getId() : null;
p.snapshotApprovedOnUserId = account.getApprovedOnByUser() != null ? account.getApprovedOnByUser().getId() : null;
p.snapshotActivatedOnUserId = account.getActivatedOnByUser() != null ? account.getActivatedOnByUser().getId() : null;
p.snapshotRejectedOnUserId = account.getRejectedOnByUser() != null ? account.getRejectedOnByUser().getId() : null;
p.snapshotWithdrawnOnUserId = account.getWithdrawnOnByUser() != null ? account.getWithdrawnOnByUser().getId() : null;
p.snapshotClosedOnUserId = account.getClosedOnByUser() != null ? account.getClosedOnByUser().getId() : null;
p.snapshotCurrencyCode = account.getCurrencyCode();
p.snapshotCurrencyDigits = account.getCurrencyDigits();
p.snapshotCurrencyMultiplesOf = account.getCurrencyMultiplesOf();
p.snapshotNominalAnnualInterestRate = account.getNominalAnnualInterestRate();
p.snapshotInterestCompoundingPeriodEnum = account.getInterestCompoundingPeriodEnum();
p.snapshotInterestPostingPeriodEnum = account.getInterestPostingPeriodEnum();
p.snapshotInterestCalculationTypeEnum = account.getInterestCalculationTypeEnum();
p.snapshotInterestCalculationDaysInYearTypeEnum = account.getInterestCalculationDaysInYearTypeEnum();
p.snapshotMinBalanceForInterestCalculation = account.getMinBalanceForInterestCalculation();
p.snapshotStartInterestCalculationDate = account.getStartInterestCalculationDate();
p.snapshotAccruedTillDate = account.getAccruedTillDate();
p.snapshotLastInterestCalculationDate = account.getLastInterestCalculationDate();
p.snapshotLastClosedBusinessDate = account.getLastClosedBusinessDate();
p.snapshotWithholdTax = account.isWithholdTax();
p.snapshotMinRequiredOpeningBalance = account.getMinRequiredOpeningBalance();
p.snapshotLockinPeriodFrequency = account.getLockinPeriodFrequency();
p.snapshotLockinPeriodFrequencyEnum = account.getLockinPeriodFrequencyEnum();
p.snapshotLockedInUntilDate = account.getLockedInUntilDate();
p.snapshotEnforceMinRequiredBalance = account.isEnforceMinRequiredBalance();
p.snapshotMinRequiredBalance = account.getMinRequiredBalance();
p.snapshotAllowOverdraft = account.isAllowOverdraft();
p.snapshotOverdraftLimit = account.getOverdraftLimit();
p.snapshotNominalAnnualInterestRateOverdraft = account.getNominalAnnualInterestRateOverdraft();
p.snapshotMinOverdraftForInterestCalculation = account.getMinOverdraftForInterestCalculation();
p.snapshotLienAllowed = account.isLienAllowed();
p.snapshotMaxAllowedLienLimit = account.getMaxAllowedLienLimit();
p.snapshotOnHoldFundsDerived = account.getOnHoldFundsDerived();
p.snapshotAccountBalanceDerived = account.getAccountBalanceDerived();
p.snapshotTotalDepositsDerived = account.getTotalDepositsDerived();
p.snapshotTotalWithdrawalsDerived = account.getTotalWithdrawalsDerived();
p.snapshotTotalInterestEarnedDerived = account.getTotalInterestEarnedDerived();
p.snapshotTotalInterestPostedDerived = account.getTotalInterestPostedDerived();
p.snapshotTotalOverdraftInterestDerived = account.getTotalOverdraftInterestDerived();
p.snapshotTotalWithholdTaxDerived = account.getTotalWithholdTaxDerived();
p.snapshotTotalSavingsAmountOnHold = account.getTotalSavingsAmountOnHold();
p.snapshotWithdrawalFeeForTransfer = account.isWithdrawalFeeForTransfer();
p.snapshotReasonForBlock = account.getReasonForBlock();
p.snapshotMsme = account.isMsme();
p.snapshotAutoDebitFacility = account.isAutoDebitFacility();
p.snapshotBalanceSwapOption = account.isBalanceSwapOption();
p.snapshotEstablishmentDate = account.getEstablishmentDate();
p.snapshotStartOperationDate = account.getStartOperationDate();
p.snapshotNonIndividualClient = account.isNonIndividualClient();
p.snapshotSignatoryRights = account.getSignatoryRights();
p.snapshotCbsAccountType = account.getCbsAccountType();
p.snapshotCustomerSegment = account.getCustomerSegment();
p.snapshotAccountStatus = account.getAccountStatus();
p.snapshotModeOfOperation = account.getModeOfOperation();
p.snapshotSpecialCategory = account.getSpecialCategory();
p.snapshotIntroducerClientId = account.getIntroducerClientId();
p.snapshotIntroducerName = account.getIntroducerName();
p.snapshotIntroducerCifNumber = account.getIntroducerCifNumber();
p.snapshotNomineeData = account.getNomineeData();
p.snapshotInterestCalculateSelector = account.getInterestCalculateSelector();
p.snapshotAveragePeriod = account.getAveragePeriod();
p.snapshotAutoPost = account.isAutoPost();
p.snapshotFrequencyFixDay = account.getFrequencyFixDay();
p.snapshotEnableLockinPeriod = account.isEnableLockinPeriod();
p.snapshotBalanceFrequency = account.getBalanceFrequency();
p.snapshotAvgBalanceCalcPeriod = account.getAvgBalanceCalcPeriod();
p.snapshotAccountClosedZeroBalanceInDayClosing = account.getAccountClosedZeroBalanceInDayClosing();
p.snapshotValidForPeriod = account.getValidForPeriod();
p.snapshotOdAccountClosedZeroBalanceInDayClosing = account.isOdAccountClosedZeroBalanceInDayClosing();
p.snapshotShareLinkingEnabled = account.isShareLinkingEnabled();
p.snapshotTerminateTrue = account.isTerminateTrue();
return p;
}
public boolean isPending() {
return STATUS_PENDING.equals(this.status);
}
public void markApproved(final AppUser checker) {
this.status = STATUS_APPROVED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.pendingLockKey = null;
}
public void markRejected(final AppUser checker, final String reason) {
this.status = STATUS_REJECTED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,32 @@
/**
* 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.currentaccount.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CurrentAccountPendingEditRepository extends JpaRepository<CurrentAccountPendingEdit, Long> {
Optional<CurrentAccountPendingEdit> findByCurrentAccountIdAndStatus(Long currentAccountId, String status);
List<CurrentAccountPendingEdit> findByStatus(String status);
}
@@ -0,0 +1,37 @@
/**
* 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.currentaccount.exception;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown at edit-submission time when the account's available balance cannot cover the charge for
* the digital/account feature(s) the maker is requesting to enable.
*/
public class CurrentAccountEditInsufficientBalanceException extends AbstractPlatformDomainRuleException {
public CurrentAccountEditInsufficientBalanceException(final Long currentAccountId, final BigDecimal availableBalance,
final BigDecimal requiredAmount) {
super("error.msg.currentaccount.edit.insufficient.balance",
"Account balance is insufficient for the requested feature(s). Available balance: " + availableBalance
+ ", required: " + requiredAmount,
currentAccountId, availableBalance, requiredAmount);
}
}
@@ -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
*
* 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.currentaccount.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentAccountEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public CurrentAccountEditPendingApprovalException(final Long currentAccountId) {
super("error.msg.currentaccount.edit.pending.approval.exists",
"Current account with identifier " + currentAccountId + " already has a Nominee/Digital-Feature edit pending approval",
currentAccountId);
}
}
@@ -0,0 +1,31 @@
/**
* 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.currentaccount.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentAccountEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public CurrentAccountEditSelfApprovalNotAllowedException(final Long currentAccountId) {
super("error.msg.currentaccount.edit.self.approval.not.allowed",
"The maker of the pending Nominee/Digital-Feature edit for current account " + currentAccountId
+ " cannot also approve or reject it",
currentAccountId);
}
}
@@ -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
*
* 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.currentaccount.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentAccountNoPendingEditException extends AbstractPlatformDomainRuleException {
public CurrentAccountNoPendingEditException(final Long currentAccountId) {
super("error.msg.currentaccount.no.pending.edit",
"Current account with identifier " + currentAccountId + " has no Nominee/Digital-Feature edit awaiting approval",
currentAccountId);
}
}
@@ -0,0 +1,37 @@
/**
* 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.currentaccount.service;
import java.util.Collection;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData;
public interface CurrentAccountEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long currentAccountId, JsonCommand command);
CommandProcessingResult approveEdit(Long currentAccountId);
CommandProcessingResult rejectEdit(Long currentAccountId, String rejectReason);
CurrentAccountPendingEditData retrievePendingEdit(Long currentAccountId);
Collection<CurrentAccountPendingEditData> retrieveAllPendingEdits();
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.currentaccount.service;
import java.util.Set;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
@@ -42,4 +43,13 @@ public interface CurrentAccountWritePlatformService {
CommandProcessingResult deleteApplication(Long accountId);
CommandProcessingResult terminateAccount(Long accountId, JsonCommand command);
/**
* Resolves each of the given (newly-enabled) facility types to its mapped charge via
* {@code m_facility_charge_mapping}, creates a {@code CurrentAccountCharge} for it (skipping any facility type
* for which the account already has a paid, non-waived charge for the same mapped {@code Charge}), collects it
* from the account's running balance as of today, and posts the corresponding GL journal entries. Reuses the
* same private charge-creation-and-collection helper that {@code activate()} uses for activation-time charges.
*/
void applyAndCollectFacilityCharges(Long currentAccountId, Set<String> newlyEnabledFacilityTypes);
}
@@ -44,6 +44,8 @@ import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.springframework.stereotype.Service;
@@ -62,6 +64,7 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
private final ClientNomineeRepository nomineeRepository;
private final CodeValueRepositoryWrapper codeValueRepository;
private final SavingsAccountRepository savingsAccountRepository;
private final CurrentAccountRepository currentAccountRepository;
private final LoanRepository loanRepository;
private final ClientNonPersonRepositoryWrapper clientNonPersonRepository;
@@ -299,8 +302,18 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
throw new PlatformDataIntegrityException("error.msg.nominee.loan.account.not.found",
"Loan account '" + accountNo + "' not found for client " + clientId + ".", "accountNo");
}
} else if ("CB".equals(accountType)) {
final CurrentAccount ca = this.currentAccountRepository.findByAccountNo(accountNo).orElse(null);
if (ca == null) {
throw new PlatformDataIntegrityException("error.msg.nominee.current.account.not.found",
"Account '" + accountNo + "' not found.", "accountNo");
}
if (ca.getClient() == null || !ca.getClient().getId().equals(clientId)) {
throw new PlatformDataIntegrityException("error.msg.nominee.account.does.not.belong.to.client",
"Account '" + accountNo + "' does not belong to client " + clientId + ".", "accountNo");
}
} else {
// SB, CB, FD, RD are all savings accounts with different deposit_type_enum
// SB, FD, RD are all savings accounts with different deposit_type_enum
final SavingsAccount sa = savingsAccountRepository.findSavingsAccountByAccountNumber(accountNo);
if (sa == null) {
throw new PlatformDataIntegrityException("error.msg.nominee.savings.account.not.found",
@@ -39,23 +39,29 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParser;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.data.SavingsAccountData;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService;
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.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
import org.springframework.stereotype.Component;
@@ -76,6 +82,9 @@ public class CurrentAccountsApiResource {
private final CurrentAccountWritePlatformService currentAccountWritePlatformService;
private final CurrentAccountReadPlatformService currentAccountReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
@GET
@Path("template")
@@ -229,4 +238,104 @@ public class CurrentAccountsApiResource {
return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue);
}
@POST
@Path("{accountId}/edit")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a Nominee/Digital-Feature edit for a Current Account",
description = "Edits are held in m_current_account_pending_edit (a pending row there is itself the lock) and only "
+ "applied to the real account once a checker approves - see /currentaccounts/{accountId}/edit/approve and /reject below.")
@ApiResponse(responseCode = "200", description = "OK")
public String submitEdit(@PathParam("accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_CURRENTACCOUNT");
final JsonCommand command = JsonCommand.fromJsonElement(accountId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.currentAccountEditWritePlatformService.submitEditForApproval(accountId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Current Account Nominee/Digital-Feature edits pending approval")
@ApiResponse(responseCode = "200", description = "OK")
public String retrieveAllPendingEdits(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
final Collection<CurrentAccountPendingEditData> pendingEdits = this.currentAccountEditWritePlatformService
.retrieveAllPendingEdits();
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{accountId}/edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending Nominee/Digital-Feature edit for a Current Account, if any")
@ApiResponse(responseCode = "200", description = "OK")
public String retrievePendingEdit(@PathParam("accountId") final Long accountId, @Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
final CurrentAccountPendingEditData pendingEdit = this.currentAccountEditWritePlatformService.retrievePendingEdit(accountId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{accountId}/edit/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Current Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold, posts any newly-enabled feature charges, and applies the "
+ "Nominee/Digital-Feature changes to the account. The maker of the edit cannot also approve it.")
@ApiResponse(responseCode = "200", description = "OK")
public String approvePendingEdit(@PathParam("accountId") final Long accountId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CURRENTACCOUNT")) {
throw new NoAuthorizationException("User has no authority to approve current account edits");
}
final CommandProcessingResult result = this.currentAccountEditWritePlatformService.approveEdit(accountId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{accountId}/edit/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Current Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold without applying any change. The maker of the edit cannot also reject it.")
@ApiResponse(responseCode = "200", description = "OK")
public String rejectPendingEdit(@PathParam("accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_CURRENTACCOUNT")) {
throw new NoAuthorizationException("User has no authority to reject current account edits");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.currentAccountEditWritePlatformService.rejectEdit(accountId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -58,8 +58,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.UploadRequest;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.core.service.SearchParameters;
@@ -69,9 +72,11 @@ import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountTemplateReadPlatformService;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
@@ -95,6 +100,9 @@ public class SavingsAccountsApiResource {
private final BulkImportWorkbookService bulkImportWorkbookService;
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
private final SqlValidator sqlValidator;
private final SavingsAccountEditWritePlatformService savingsAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<SavingsAccountPendingEditData> pendingEditApiJsonSerializer;
@GET
@Path("template")
@@ -413,6 +421,102 @@ public class SavingsAccountsApiResource {
return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue);
}
@POST
@Path("{accountId}/edit")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a Nominee/Digital-Feature edit for a Savings Account",
description = "Edits are held in m_savings_account_pending_edit (a pending row there is itself the lock) and only "
+ "applied to the real account once a checker approves - see /savingsaccounts/{accountId}/edit/approve and /reject below.")
public String submitEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_SAVINGSACCOUNT");
final JsonCommand command = JsonCommand.fromJsonElement(accountId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.submitEditForApproval(accountId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Savings Account Nominee/Digital-Feature edits pending approval")
public String retrieveAllPendingEdits(@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME);
final Collection<SavingsAccountPendingEditData> pendingEdits = this.savingsAccountEditWritePlatformService
.retrieveAllPendingEdits();
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{accountId}/edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending Nominee/Digital-Feature edit for a Savings Account, if any")
public String retrievePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId,
@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME);
final SavingsAccountPendingEditData pendingEdit = this.savingsAccountEditWritePlatformService.retrievePendingEdit(accountId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{accountId}/edit/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Savings Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold, posts any newly-enabled feature charges, and applies the "
+ "Nominee/Digital-Feature changes to the account. The maker of the edit cannot also approve it.")
public String approvePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_SAVINGSACCOUNT")) {
throw new NoAuthorizationException("User has no authority to approve savings account edits");
}
final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.approveEdit(accountId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{accountId}/edit/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Savings Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold without applying any change. The maker of the edit cannot also reject it.")
public String rejectPendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_SAVINGSACCOUNT")) {
throw new NoAuthorizationException("User has no authority to reject savings account edits");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.rejectEdit(accountId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
@DELETE
@Path("{accountId}")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,569 @@
/**
* 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.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
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.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEdit;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEditRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditInsufficientBalanceException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditPendingApprovalException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNoPendingEditException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker edit flow for an ACTIVE current account's Nominee + Digital & Account Features. Mirrors
* {@code SavingsAccountEditWritePlatformServiceJpaRepositoryImpl}'s submitEditForApproval/approveEdit/rejectEdit
* pattern: a row in {@code m_current_account_pending_edit} is itself the lock, and nothing is applied to the real
* {@code m_current_account_facility_config} / {@code m_client_nominee} tables until a checker approves.
*
* <p>
* Charge handling for a newly-enabled facility (one with a mapped charge in m_facility_charge_mapping): at
* submission a HOLD is placed for the total charge amount (blocking that much of the available balance without
* touching it); on approval the hold is released and the charge is posted for real (via
* {@link CurrentAccountWritePlatformService#applyAndCollectFacilityCharges(Long, Set)}); on rejection the hold is
* simply released.
*/
@RequiredArgsConstructor
public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements CurrentAccountEditWritePlatformService {
/** field name (as sent in accountFacilityConfig JSON / stored on the pending edit) -> facility_type code. */
private static final Map<String, String> FACILITY_TYPES = new LinkedHashMap<>();
static {
FACILITY_TYPES.put("atmFacility", "ATM_FACILITY");
FACILITY_TYPES.put("atmWithdrawalFacility", "ATM_WITHDRAWAL");
FACILITY_TYPES.put("upiFacility", "UPI_FACILITY");
FACILITY_TYPES.put("posTransactionsFacility", "POS_TRANSACTIONS");
FACILITY_TYPES.put("chequeBook", "CHEQUE_BOOK");
FACILITY_TYPES.put("impsFacility", "IMPS_FACILITY");
FACILITY_TYPES.put("smsAlerts", "SMS_ALERTS");
FACILITY_TYPES.put("bbpsFacility", "BBPS_FACILITY");
FACILITY_TYPES.put("insuranceCover", "INSURANCE_COVER");
FACILITY_TYPES.put("bepgFacility", "BEPG_FACILITY");
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING");
FACILITY_TYPES.put("mobileBanking", "MOBILE_BANKING");
}
private final PlatformSecurityContext context;
private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountPendingEditRepository pendingEditRepository;
private final CurrentAccountFacilityConfigRepository facilityConfigRepository;
private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository;
private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService;
private final CurrentAccountWritePlatformService currentAccountWritePlatformService;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final CommandSourceRepository commandSourceRepository;
private final FromJsonHelper fromApiJsonHelper;
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long currentAccountId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final CurrentAccount account = this.currentAccountRepository.findById(currentAccountId)
.orElseThrow(() -> new CurrentAccountNotFoundException(currentAccountId));
if (this.pendingEditRepository.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.isPresent()) {
throw new CurrentAccountEditPendingApprovalException(currentAccountId);
}
final JsonObject root = command.parsedJson().getAsJsonObject();
final CurrentAccountFacilityConfig currentConfig = account.getFacilityConfig();
final CurrentAccountPendingEdit pendingEdit = CurrentAccountPendingEdit.snapshotOf(account, maker);
// ── Digital & Account Features ──
final Set<String> newlyEnabledFacilityTypes = new HashSet<>();
if (root.has("accountFacilityConfig") && root.get("accountFacilityConfig").isJsonObject()) {
final JsonObject fc = root.getAsJsonObject("accountFacilityConfig");
pendingEdit.setFacilityConfigSubmitted(true);
populateFacilityField(fc, "atmFacility", currentConfig != null && currentConfig.isAtmFacility(), pendingEdit::setAtmFacility,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "atmWithdrawalFacility", currentConfig != null && currentConfig.isAtmWithdrawalFacility(),
pendingEdit::setAtmWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "upiFacility", currentConfig != null && currentConfig.isUpiFacility(), pendingEdit::setUpiFacility,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "posTransactionsFacility", currentConfig != null && currentConfig.isPosTransactionsFacility(),
pendingEdit::setPosTransactionsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "chequeBook", currentConfig != null && currentConfig.isChequeBook(), pendingEdit::setChequeBook,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "impsFacility", currentConfig != null && currentConfig.isImpsFacility(),
pendingEdit::setImpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "smsAlerts", currentConfig != null && currentConfig.isSmsAlerts(), pendingEdit::setSmsAlerts,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "bbpsFacility", currentConfig != null && currentConfig.isBbpsFacility(),
pendingEdit::setBbpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "insuranceCover", currentConfig != null && currentConfig.isInsuranceCover(),
pendingEdit::setInsuranceCover, newlyEnabledFacilityTypes);
populateFacilityField(fc, "bepgFacility", currentConfig != null && currentConfig.isBepgFacility(),
pendingEdit::setBepgFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "neftFacility", currentConfig != null && currentConfig.isNeftFacility(),
pendingEdit::setNeftFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "rtgsFacility", currentConfig != null && currentConfig.isRtgsFacility(),
pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(),
pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "withdrawalFacility", currentConfig != null && currentConfig.isWithdrawalFacility(),
pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(),
pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(),
pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes);
populateFacilityField(fc, "internetBanking", currentConfig != null && currentConfig.isInternetBanking(),
pendingEdit::setInternetBanking, newlyEnabledFacilityTypes);
populateFacilityField(fc, "mobileBanking", currentConfig != null && currentConfig.isMobileBanking(),
pendingEdit::setMobileBanking, newlyEnabledFacilityTypes);
}
// ── Nominee (single add / update / delete per request) ──
if (root.has("nominee") && root.get("nominee").isJsonObject()) {
final JsonObject nom = root.getAsJsonObject("nominee");
pendingEdit.setNomineeSubmitted(true);
pendingEdit.setNomineeOperation(nom.has("operation") ? nom.get("operation").getAsString() : null);
pendingEdit.setNomineeId(nom.has("nomineeId") && !nom.get("nomineeId").isJsonNull() ? nom.get("nomineeId").getAsLong() : null);
pendingEdit.setNomineeClientId(
nom.has("nomineeClientId") && !nom.get("nomineeClientId").isJsonNull() ? nom.get("nomineeClientId").getAsLong()
: null);
pendingEdit.setRelationshipCvId(
nom.has("relationshipId") && !nom.get("relationshipId").isJsonNull() ? nom.get("relationshipId").getAsLong() : null);
pendingEdit.setPercentageShare(
nom.has("percentageShare") && !nom.get("percentageShare").isJsonNull() ? nom.get("percentageShare").getAsBigDecimal()
: null);
pendingEdit.setNomineeIsActive(
nom.has("isActive") && !nom.get("isActive").isJsonNull() ? nom.get("isActive").getAsBoolean() : null);
pendingEdit.setNomineeIsMinor(
nom.has("nomineeIsMinor") && !nom.get("nomineeIsMinor").isJsonNull() ? nom.get("nomineeIsMinor").getAsBoolean()
: null);
pendingEdit.setGuardianClientId(
nom.has("guardianClientId") && !nom.get("guardianClientId").isJsonNull() ? nom.get("guardianClientId").getAsLong()
: null);
pendingEdit.setGuardianRelationshipCvId(
nom.has("guardianRelationshipId") && !nom.get("guardianRelationshipId").isJsonNull()
? nom.get("guardianRelationshipId").getAsLong()
: null);
pendingEdit.setEffectiveDate(
nom.has("effectiveDate") && !nom.get("effectiveDate").isJsonNull() ? LocalDate.parse(nom.get("effectiveDate").getAsString())
: null);
pendingEdit.setNomineeRemarks(nom.has("remarks") && !nom.get("remarks").isJsonNull() ? nom.get("remarks").getAsString() : null);
}
// ── Hold the charge amount for newly-enabled facilities before anything is persisted ──
final BigDecimal holdNeeded = totalChargeAmountFor(newlyEnabledFacilityTypes);
if (holdNeeded.compareTo(BigDecimal.ZERO) > 0) {
final BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived()
: BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal availableBalance = currentBalance.subtract(onHold);
if (availableBalance.compareTo(holdNeeded) < 0) {
throw new CurrentAccountEditInsufficientBalanceException(currentAccountId, availableBalance, holdNeeded);
}
final JsonObject holdJson = new JsonObject();
holdJson.addProperty("locale", "en");
holdJson.addProperty("dateFormat", "yyyy-MM-dd");
holdJson.addProperty("transactionDate", DateUtils.getBusinessLocalDate().toString());
holdJson.addProperty("transactionAmount", holdNeeded);
holdJson.addProperty("reasonForBlock", "Pending approval of Nominee/Digital-Feature edit");
final JsonCommand holdCommand = JsonCommand.fromJsonElement(currentAccountId, holdJson, this.fromApiJsonHelper);
final CommandProcessingResult holdResult = this.currentAccountTransactionWritePlatformService.holdAmount(currentAccountId,
holdCommand);
pendingEdit.setHoldTransactionId(holdResult.getResourceId());
pendingEdit.setHoldAmount(holdNeeded);
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("SUBMITEDIT") //
.entityName("CURRENTACCOUNT") //
.resourceGetUrl("/currentaccounts/" + currentAccountId) //
.resourceId(currentAccountId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long currentAccountId) {
final AppUser checker = this.context.authenticatedUser();
final CurrentAccountPendingEdit pendingEdit = this.pendingEditRepository
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
final CurrentAccount account = this.currentAccountRepository.findById(currentAccountId)
.orElseThrow(() -> new CurrentAccountNotFoundException(currentAccountId));
final CurrentAccountFacilityConfig currentConfig = account.getFacilityConfig();
final Set<String> newlyEnabledFacilityTypes = pendingEdit.isFacilityConfigSubmitted()
? computeNewlyEnabled(pendingEdit, currentConfig)
: Set.of();
// 1) Release the hold placed at submission time.
if (pendingEdit.getHoldTransactionId() != null) {
this.currentAccountTransactionWritePlatformService.releaseAmount(currentAccountId, pendingEdit.getHoldTransactionId());
}
// 2) Post the real charge(s) for newly-enabled facilities, today.
if (!newlyEnabledFacilityTypes.isEmpty()) {
this.currentAccountWritePlatformService.applyAndCollectFacilityCharges(currentAccountId, newlyEnabledFacilityTypes);
}
// 3) Apply the facility-config toggles to the real row.
if (pendingEdit.isFacilityConfigSubmitted()) {
CurrentAccountFacilityConfig config = account.getFacilityConfig();
if (config == null) {
config = CurrentAccountFacilityConfig.createFor(account);
account.setFacilityConfig(config);
}
config.updateFrom(buildFacilityConfigJson(pendingEdit));
this.facilityConfigRepository.saveAndFlush(config);
}
// 4) Apply the nominee change to the real table.
if (pendingEdit.isNomineeSubmitted()) {
applyNomineeChange(account, pendingEdit);
}
this.currentAccountRepository.saveAndFlush(account);
pendingEdit.markApproved(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, false);
return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build();
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long currentAccountId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final CurrentAccountPendingEdit pendingEdit = this.pendingEditRepository
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
if (pendingEdit.getHoldTransactionId() != null) {
this.currentAccountTransactionWritePlatformService.releaseAmount(currentAccountId, pendingEdit.getHoldTransactionId());
}
pendingEdit.markRejected(checker, rejectReason);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, true);
return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build();
}
@Override
public CurrentAccountPendingEditData retrievePendingEdit(final Long currentAccountId) {
return this.pendingEditRepository.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.map(this::toData).orElse(null);
}
@Override
public java.util.Collection<CurrentAccountPendingEditData> retrieveAllPendingEdits() {
return this.pendingEditRepository.findByStatus(CurrentAccountPendingEdit.STATUS_PENDING).stream().map(this::toData).toList();
}
// ── helpers ──────────────────────────────────────────────────────────────
/**
* Sum of the mapped charge amounts for the given facility types - used to size the hold placed at
* edit-submission time, before any charge is actually applied.
*/
private BigDecimal totalChargeAmountFor(final Set<String> facilityTypes) {
BigDecimal total = BigDecimal.ZERO;
for (final String facilityType : facilityTypes) {
final var mapping = this.facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mapping.isPresent() && mapping.get().getCharge() != null) {
total = total.add(mapping.get().getCharge().getAmount());
}
}
return total;
}
private void applyNomineeChange(final CurrentAccount account, final CurrentAccountPendingEdit pendingEdit) {
final Long clientId = account.getClient() != null ? account.getClient().getId() : null;
final String operation = pendingEdit.getNomineeOperation();
if (CurrentAccountPendingEdit.NOMINEE_OP_DELETE.equals(operation)) {
this.clientNomineeWritePlatformService.deleteNominee(clientId, pendingEdit.getNomineeId());
return;
}
final JsonObject nomineeJson = new JsonObject();
nomineeJson.addProperty("locale", "en");
nomineeJson.addProperty("dateFormat", "yyyy-MM-dd");
nomineeJson.addProperty("accountNo", account.getAccountNo());
nomineeJson.addProperty("accountType", "CB");
if (pendingEdit.getNomineeClientId() != null) {
nomineeJson.addProperty("nomineeClientId", pendingEdit.getNomineeClientId());
}
if (pendingEdit.getRelationshipCvId() != null) {
nomineeJson.addProperty("relationshipId", pendingEdit.getRelationshipCvId());
}
if (pendingEdit.getPercentageShare() != null) {
nomineeJson.addProperty("percentageShare", pendingEdit.getPercentageShare());
}
if (pendingEdit.getNomineeIsActive() != null) {
nomineeJson.addProperty("isActive", pendingEdit.getNomineeIsActive());
}
if (pendingEdit.getNomineeIsMinor() != null) {
nomineeJson.addProperty("nomineeIsMinor", pendingEdit.getNomineeIsMinor());
}
if (pendingEdit.getGuardianClientId() != null) {
nomineeJson.addProperty("guardianClientId", pendingEdit.getGuardianClientId());
}
if (pendingEdit.getGuardianRelationshipCvId() != null) {
nomineeJson.addProperty("guardianRelationshipId", pendingEdit.getGuardianRelationshipCvId());
}
if (pendingEdit.getEffectiveDate() != null) {
nomineeJson.addProperty("effectiveDate", pendingEdit.getEffectiveDate().toString());
}
if (pendingEdit.getNomineeRemarks() != null) {
nomineeJson.addProperty("remarks", pendingEdit.getNomineeRemarks());
}
final JsonCommand nomineeCommand = JsonCommand.fromJsonElement(clientId, nomineeJson, this.fromApiJsonHelper);
if (CurrentAccountPendingEdit.NOMINEE_OP_ADD.equals(operation)) {
this.clientNomineeWritePlatformService.addNominee(clientId, nomineeCommand);
} else if (CurrentAccountPendingEdit.NOMINEE_OP_UPDATE.equals(operation)) {
this.clientNomineeWritePlatformService.updateNominee(clientId, pendingEdit.getNomineeId(), nomineeCommand);
}
}
private JsonObject buildFacilityConfigJson(final CurrentAccountPendingEdit p) {
final JsonObject fc = new JsonObject();
putIfPresent(fc, "atmFacility", p.getAtmFacility());
putIfPresent(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfPresent(fc, "upiFacility", p.getUpiFacility());
putIfPresent(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfPresent(fc, "chequeBook", p.getChequeBook());
putIfPresent(fc, "impsFacility", p.getImpsFacility());
putIfPresent(fc, "smsAlerts", p.getSmsAlerts());
putIfPresent(fc, "bbpsFacility", p.getBbpsFacility());
putIfPresent(fc, "insuranceCover", p.getInsuranceCover());
putIfPresent(fc, "bepgFacility", p.getBepgFacility());
putIfPresent(fc, "neftFacility", p.getNeftFacility());
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
putIfPresent(fc, "internetBanking", p.getInternetBanking());
putIfPresent(fc, "mobileBanking", p.getMobileBanking());
return fc;
}
private void putIfPresent(final JsonObject json, final String field, final Boolean value) {
if (value != null) {
json.addProperty(field, value);
}
}
private Set<String> computeNewlyEnabled(final CurrentAccountPendingEdit p, final CurrentAccountFacilityConfig current) {
final Set<String> result = new HashSet<>();
addIfNewlyEnabled(result, "atmFacility", p.getAtmFacility(), current != null && current.isAtmFacility());
addIfNewlyEnabled(result, "atmWithdrawalFacility", p.getAtmWithdrawalFacility(), current != null && current.isAtmWithdrawalFacility());
addIfNewlyEnabled(result, "upiFacility", p.getUpiFacility(), current != null && current.isUpiFacility());
addIfNewlyEnabled(result, "posTransactionsFacility", p.getPosTransactionsFacility(),
current != null && current.isPosTransactionsFacility());
addIfNewlyEnabled(result, "chequeBook", p.getChequeBook(), current != null && current.isChequeBook());
addIfNewlyEnabled(result, "impsFacility", p.getImpsFacility(), current != null && current.isImpsFacility());
addIfNewlyEnabled(result, "smsAlerts", p.getSmsAlerts(), current != null && current.isSmsAlerts());
addIfNewlyEnabled(result, "bbpsFacility", p.getBbpsFacility(), current != null && current.isBbpsFacility());
addIfNewlyEnabled(result, "insuranceCover", p.getInsuranceCover(), current != null && current.isInsuranceCover());
addIfNewlyEnabled(result, "bepgFacility", p.getBepgFacility(), current != null && current.isBepgFacility());
addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current != null && current.isNeftFacility());
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility());
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(),
current != null && current.isInterBankTotalCapFacility());
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current != null && current.isWithdrawalFacility());
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc());
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard());
addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking());
addIfNewlyEnabled(result, "mobileBanking", p.getMobileBanking(), current != null && current.isMobileBanking());
return result;
}
private void addIfNewlyEnabled(final Set<String> result, final String field, final Boolean proposed, final boolean current) {
if (Boolean.TRUE.equals(proposed) && !current) {
result.add(FACILITY_TYPES.get(field));
}
}
/** Reads a single submitted field out of the incoming JSON, tracks whether it enables a previously-off facility. */
private void populateFacilityField(final JsonObject fc, final String field, final boolean currentValue,
final java.util.function.Consumer<Boolean> setter, final Set<String> newlyEnabledCollector) {
if (!fc.has(field) || fc.get(field).isJsonNull()) {
return;
}
final boolean proposed = fc.get(field).getAsBoolean();
setter.accept(proposed);
if (proposed && !currentValue) {
newlyEnabledCollector.add(FACILITY_TYPES.get(field));
}
}
private void updateAuditEntry(final CurrentAccountPendingEdit pendingEdit, final AppUser checker, final boolean rejected) {
Optional<CommandSource> existing = Optional.empty();
if (pendingEdit.getCommandSourceId() != null) {
existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
}
final CommandSource auditEntry = existing.orElseGet(() -> CommandSource.builder() //
.actionName(rejected ? "REJECTEDIT" : "APPROVEEDIT") //
.entityName("CURRENTACCOUNT") //
.resourceGetUrl("/currentaccounts/" + pendingEdit.getCurrentAccount().getId()) //
.resourceId(pendingEdit.getCurrentAccount().getId()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build());
if (rejected) {
auditEntry.markAsRejected(checker);
} else {
auditEntry.markAsChecked(checker);
}
this.commandSourceRepository.saveAndFlush(auditEntry);
}
private CurrentAccountPendingEditData toData(final CurrentAccountPendingEdit p) {
final CurrentAccountPendingEditData data = new CurrentAccountPendingEditData();
data.setId(p.getId());
data.setCurrentAccountId(p.getCurrentAccount().getId());
data.setAccountNo(p.getCurrentAccount().getAccountNo());
data.setMakerId(p.getMaker().getId());
data.setMakerUsername(p.getMaker().getUsername());
if (p.getChecker() != null) {
data.setCheckerId(p.getChecker().getId());
data.setCheckerUsername(p.getChecker().getUsername());
}
data.setCreatedOn(p.getCreatedOn());
data.setCheckedOn(p.getCheckedOn());
data.setStatus(p.getStatus());
data.setRejectReason(p.getRejectReason());
data.setHoldAmount(p.getHoldAmount());
data.setFacilityConfigSubmitted(p.isFacilityConfigSubmitted());
data.setNomineeSubmitted(p.isNomineeSubmitted());
if (p.isFacilityConfigSubmitted()) {
final Map<String, Object> fc = new LinkedHashMap<>();
putIfNotNull(fc, "atmFacility", p.getAtmFacility());
putIfNotNull(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfNotNull(fc, "upiFacility", p.getUpiFacility());
putIfNotNull(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfNotNull(fc, "chequeBook", p.getChequeBook());
putIfNotNull(fc, "impsFacility", p.getImpsFacility());
putIfNotNull(fc, "smsAlerts", p.getSmsAlerts());
putIfNotNull(fc, "bbpsFacility", p.getBbpsFacility());
putIfNotNull(fc, "insuranceCover", p.getInsuranceCover());
putIfNotNull(fc, "bepgFacility", p.getBepgFacility());
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
putIfNotNull(fc, "internetBanking", p.getInternetBanking());
putIfNotNull(fc, "mobileBanking", p.getMobileBanking());
data.setProposedFacilityConfig(fc);
}
if (p.isNomineeSubmitted()) {
final Map<String, Object> nom = new LinkedHashMap<>();
putIfNotNull(nom, "operation", p.getNomineeOperation());
putIfNotNull(nom, "nomineeId", p.getNomineeId());
putIfNotNull(nom, "nomineeClientId", p.getNomineeClientId());
putIfNotNull(nom, "relationshipId", p.getRelationshipCvId());
putIfNotNull(nom, "percentageShare", p.getPercentageShare());
putIfNotNull(nom, "isActive", p.getNomineeIsActive());
putIfNotNull(nom, "nomineeIsMinor", p.getNomineeIsMinor());
putIfNotNull(nom, "guardianClientId", p.getGuardianClientId());
putIfNotNull(nom, "guardianRelationshipId", p.getGuardianRelationshipCvId());
putIfNotNull(nom, "effectiveDate", p.getEffectiveDate());
putIfNotNull(nom, "remarks", p.getNomineeRemarks());
data.setProposedNominee(nom);
}
return data;
}
private void putIfNotNull(final Map<String, Object> map, final String key, final Object value) {
if (value != null) {
map.put(key, value);
}
}
}
@@ -70,9 +70,37 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
response.put("partners", retrievePartners(accountId));
response.put("charges", retrieveCharges(accountId));
response.put("transactions", retrieveTransactions(accountId));
response.put("accountFacilityConfig", retrieveFacilityConfig(accountId));
return response;
}
private Map<String, Object> retrieveFacilityConfig(Long accountId) {
final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?";
List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> cfg = new LinkedHashMap<>();
cfg.put("atmFacility", rs.getBoolean("atm_facility"));
cfg.put("atmWithdrawalFacility", rs.getBoolean("atm_withdrawal_facility"));
cfg.put("upiFacility", rs.getBoolean("upi_facility"));
cfg.put("posTransactionsFacility", rs.getBoolean("pos_transactions_facility"));
cfg.put("chequeBook", rs.getBoolean("cheque_book"));
cfg.put("impsFacility", rs.getBoolean("imps_facility"));
cfg.put("smsAlerts", rs.getBoolean("sms_alerts"));
cfg.put("bbpsFacility", rs.getBoolean("bbps_facility"));
cfg.put("insuranceCover", rs.getBoolean("insurance_cover"));
cfg.put("bepgFacility", rs.getBoolean("bepg_facility"));
cfg.put("neftFacility", rs.getBoolean("neft_facility"));
cfg.put("rtgsFacility", rs.getBoolean("rtgs_facility"));
cfg.put("interBankTotalCapFacility", rs.getBoolean("inter_bank_total_cap_facility"));
cfg.put("withdrawalFacility", rs.getBoolean("withdrawal_facility"));
cfg.put("depositFacilityKyc", rs.getBoolean("deposit_facility_kyc"));
cfg.put("virtualDebitCard", rs.getBoolean("virtual_debit_card"));
cfg.put("internetBanking", rs.getBoolean("internet_banking"));
cfg.put("mobileBanking", rs.getBoolean("mobile_banking"));
return cfg;
}, accountId);
return rows.isEmpty() ? null : rows.get(0);
}
private List<Map<String, Object>> retrieveJointHolders(Long accountId) {
final String sql =
"SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
@@ -593,8 +621,18 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
private Map<String, Object> buildSummary(Map<String, Object> row) {
Map<String, Object> s = new LinkedHashMap<>();
s.put("accountBalance", row.get("account_balance_derived"));
s.put("availableBalance", row.get("account_balance_derived"));
BigDecimal balance = toBigDecimal(row.get("account_balance_derived"));
if (balance == null) {
balance = BigDecimal.ZERO;
}
BigDecimal onHold = toBigDecimal(row.get("on_hold_funds_derived"));
if (onHold == null) {
onHold = BigDecimal.ZERO;
}
BigDecimal available = balance.subtract(onHold);
s.put("accountBalance", balance);
s.put("availableBalance", available);
s.put("totalDeposits", row.get("total_deposits_derived"));
s.put("totalWithdrawals", row.get("total_withdrawals_derived"));
s.put("totalInterestEarned", row.get("total_interest_earned_derived"));
@@ -605,7 +643,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
s.put("totalWithdrawalFees", null);
s.put("totalAnnualFees", null);
s.put("lastInterestCalculationDate", row.get("last_interest_calculation_date"));
s.put("onHoldFunds", row.get("on_hold_funds_derived"));
s.put("onHoldFunds", onHold);
return s;
}
@@ -30,6 +30,7 @@ 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.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
@@ -335,25 +336,11 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : account.getCharges()) {
if (charge.getChargeTimeEnum() != null && charge.getChargeTimeEnum() == 3
&& charge.getAmount() != null
&& charge.getAmount().compareTo(java.math.BigDecimal.ZERO) > 0
&& charge.getCharge() != null) {
charge.setAmountPaidDerived(charge.getAmount());
charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO);
charge.setPaid(true);
currentBalance = currentBalance.subtract(charge.getAmount());
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.chargeDeduction(account, account.getOffice(), charge.getAmount(), activatedOnDate);
chargeTxn.setRunningBalanceDerived(currentBalance);
currentAccountTransactionRepository.saveAndFlush(chargeTxn);
chargeEntries.add(new ChargeGlEntry(chargeTxn, charge));
log.info("Auto-deducted activation charge '{}' {} from account id={}, running balance={}",
charge.getCharge().getName(), charge.getAmount(), accountId, currentBalance);
}
}
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> activationCharges =
account.getCharges().stream()
.filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid())
.toList();
currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries);
account.setAccountBalanceDerived(currentBalance);
currentAccountRepository.saveAndFlush(account);
@@ -716,6 +703,78 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {}
/**
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
* posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and
* {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the
* charge-collection logic isn't duplicated. Returns the resulting balance.
*/
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
java.math.BigDecimal runningBalance, final LocalDate transactionDate,
final java.util.List<ChargeGlEntry> chargeEntriesOut) {
for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) {
if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) {
continue;
}
charge.setAmountPaidDerived(charge.getAmount());
charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO);
charge.setPaid(true);
runningBalance = runningBalance.subtract(charge.getAmount());
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate);
chargeTxn.setRunningBalanceDerived(runningBalance);
currentAccountTransactionRepository.saveAndFlush(chargeTxn);
chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge));
log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}",
charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance);
}
return runningBalance;
}
@Override
@Transactional
public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set<String> newlyEnabledFacilityTypes) {
if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) {
return;
}
final CurrentAccount account = findAccount(currentAccountId);
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> newCharges = new java.util.ArrayList<>();
for (final String facilityType : newlyEnabledFacilityTypes) {
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} newly-enabled via edit-approval but NO mapping found in m_facility_charge_mapping",
facilityType);
continue;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
continue;
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge);
account.getCharges().add(newCharge);
newCharges.add(newCharge);
}
if (newCharges.isEmpty()) {
return;
}
java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived()
: java.math.BigDecimal.ZERO;
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries);
account.setAccountBalanceDerived(currentBalance);
currentAccountRepository.saveAndFlush(account);
postGlEntries(account, java.util.List.of(), chargeEntries);
}
// entity type for savings/current accounts in acc_gl_journal_entry
private static final int ENTITY_TYPE_SAVINGS = 2;
@@ -0,0 +1,510 @@
/**
* 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.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
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.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService;
import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEdit;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEditRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditInsufficientBalanceException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditPendingApprovalException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNoPendingEditException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker edit flow for an ACTIVE savings account's Nominee + Digital & Account Features. Mirrors
* {@code SavingsProductWritePlatformServiceJpaRepositoryImpl}'s submitEditForApproval/approveEdit/rejectEdit
* pattern: a row in {@code m_savings_account_pending_edit} is itself the lock, and nothing is applied to the real
* {@code m_savings_account_facility_config} / {@code m_client_nominee} tables until a checker approves.
*
* <p>
* Charge handling for a newly-enabled facility (one with a mapped charge in m_facility_charge_mapping): at
* submission a HOLD is placed for the total charge amount (blocking that much of the available balance without
* touching it); on approval the hold is released and the charge is posted for real; on rejection the hold is simply
* released.
*/
@RequiredArgsConstructor
public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements SavingsAccountEditWritePlatformService {
/** field name (as sent in accountFacilityConfig JSON / stored on the pending edit) -> facility_type code. */
private static final Map<String, String> FACILITY_TYPES = new LinkedHashMap<>();
static {
FACILITY_TYPES.put("atmFacility", "ATM_FACILITY");
FACILITY_TYPES.put("atmWithdrawalFacility", "ATM_WITHDRAWAL");
FACILITY_TYPES.put("upiFacility", "UPI_FACILITY");
FACILITY_TYPES.put("posTransactionsFacility", "POS_TRANSACTIONS");
FACILITY_TYPES.put("chequeBook", "CHEQUE_BOOK");
FACILITY_TYPES.put("impsFacility", "IMPS_FACILITY");
FACILITY_TYPES.put("smsAlerts", "SMS_ALERTS");
FACILITY_TYPES.put("bbpsFacility", "BBPS_FACILITY");
FACILITY_TYPES.put("insuranceCover", "INSURANCE_COVER");
FACILITY_TYPES.put("bepgFacility", "BEPG_FACILITY");
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
}
private final PlatformSecurityContext context;
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
private final SavingsAccountPendingEditRepository pendingEditRepository;
private final SavingsAccountFacilityConfigRepository facilityConfigRepository;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
private final SavingsAccountWritePlatformService savingsAccountWritePlatformService;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final CommandSourceRepository commandSourceRepository;
private final FromJsonHelper fromApiJsonHelper;
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long savingsId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final SavingsAccount account = this.savingAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsId);
if (this.pendingEditRepository.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING).isPresent()) {
throw new SavingsAccountEditPendingApprovalException(savingsId);
}
final JsonObject root = command.parsedJson().getAsJsonObject();
final SavingsAccountFacilityConfig currentConfig = account.getFacilityConfig();
final SavingsAccountPendingEdit pendingEdit = new SavingsAccountPendingEdit(account, maker);
// ── Digital & Account Features ──
final Set<String> newlyEnabledFacilityTypes = new HashSet<>();
if (root.has("accountFacilityConfig") && root.get("accountFacilityConfig").isJsonObject()) {
final JsonObject fc = root.getAsJsonObject("accountFacilityConfig");
pendingEdit.setFacilityConfigSubmitted(true);
populateFacilityField(fc, "atmFacility", currentConfig == null ? null : currentConfig.getAtmFacility(), pendingEdit::setAtmFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "atmWithdrawalFacility", currentConfig == null ? null : currentConfig.getAtmWithdrawalFacility(), pendingEdit::setAtmWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "upiFacility", currentConfig == null ? null : currentConfig.getUpiFacility(), pendingEdit::setUpiFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "posTransactionsFacility", currentConfig == null ? null : currentConfig.getPosTransactionsFacility(), pendingEdit::setPosTransactionsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "chequeBook", currentConfig == null ? null : currentConfig.getChequeBook(), pendingEdit::setChequeBook, newlyEnabledFacilityTypes);
populateFacilityField(fc, "impsFacility", currentConfig == null ? null : currentConfig.getImpsFacility(), pendingEdit::setImpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "smsAlerts", currentConfig == null ? null : currentConfig.getSmsAlerts(), pendingEdit::setSmsAlerts, newlyEnabledFacilityTypes);
populateFacilityField(fc, "bbpsFacility", currentConfig == null ? null : currentConfig.getBbpsFacility(), pendingEdit::setBbpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "insuranceCover", currentConfig == null ? null : currentConfig.getInsuranceCover(), pendingEdit::setInsuranceCover, newlyEnabledFacilityTypes);
populateFacilityField(fc, "bepgFacility", currentConfig == null ? null : currentConfig.getBepgFacility(), pendingEdit::setBepgFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "neftFacility", currentConfig == null ? null : currentConfig.getNeftFacility(), pendingEdit::setNeftFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig == null ? null : currentConfig.getInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "withdrawalFacility", currentConfig == null ? null : currentConfig.getWithdrawalFacility(), pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "depositFacilityKyc", currentConfig == null ? null : currentConfig.getDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
populateFacilityField(fc, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes);
}
// ── Nominee (single add / update / delete per request) ──
if (root.has("nominee") && root.get("nominee").isJsonObject()) {
final JsonObject nom = root.getAsJsonObject("nominee");
pendingEdit.setNomineeSubmitted(true);
pendingEdit.setNomineeOperation(nom.has("operation") ? nom.get("operation").getAsString() : null);
pendingEdit.setNomineeId(nom.has("nomineeId") && !nom.get("nomineeId").isJsonNull() ? nom.get("nomineeId").getAsLong() : null);
pendingEdit.setNomineeClientId(nom.has("nomineeClientId") && !nom.get("nomineeClientId").isJsonNull() ? nom.get("nomineeClientId").getAsLong() : null);
pendingEdit.setRelationshipCvId(nom.has("relationshipId") && !nom.get("relationshipId").isJsonNull() ? nom.get("relationshipId").getAsLong() : null);
pendingEdit.setPercentageShare(nom.has("percentageShare") && !nom.get("percentageShare").isJsonNull() ? nom.get("percentageShare").getAsBigDecimal() : null);
pendingEdit.setNomineeIsActive(nom.has("isActive") && !nom.get("isActive").isJsonNull() ? nom.get("isActive").getAsBoolean() : null);
pendingEdit.setNomineeIsMinor(nom.has("nomineeIsMinor") && !nom.get("nomineeIsMinor").isJsonNull() ? nom.get("nomineeIsMinor").getAsBoolean() : null);
pendingEdit.setGuardianClientId(nom.has("guardianClientId") && !nom.get("guardianClientId").isJsonNull() ? nom.get("guardianClientId").getAsLong() : null);
pendingEdit.setGuardianRelationshipCvId(nom.has("guardianRelationshipId") && !nom.get("guardianRelationshipId").isJsonNull() ? nom.get("guardianRelationshipId").getAsLong() : null);
pendingEdit.setEffectiveDate(nom.has("effectiveDate") && !nom.get("effectiveDate").isJsonNull() ? LocalDate.parse(nom.get("effectiveDate").getAsString()) : null);
pendingEdit.setNomineeRemarks(nom.has("remarks") && !nom.get("remarks").isJsonNull() ? nom.get("remarks").getAsString() : null);
}
// ── Hold the charge amount for newly-enabled facilities before anything is persisted ──
final BigDecimal holdNeeded = this.facilityChargeAutoApplyService.totalChargeAmountFor(newlyEnabledFacilityTypes);
if (holdNeeded.compareTo(BigDecimal.ZERO) > 0) {
final BigDecimal availableBalance = account.getWithdrawableBalance();
if (availableBalance.compareTo(holdNeeded) < 0) {
throw new SavingsAccountEditInsufficientBalanceException(savingsId, availableBalance, holdNeeded);
}
final JsonObject holdJson = new JsonObject();
holdJson.addProperty("locale", "en");
holdJson.addProperty("dateFormat", "yyyy-MM-dd");
holdJson.addProperty("transactionDate", DateUtils.getBusinessLocalDate().toString());
holdJson.addProperty("transactionAmount", holdNeeded);
holdJson.addProperty("lienAllowed", false);
holdJson.addProperty("reasonForBlock", "Pending approval of Nominee/Digital-Feature edit");
final JsonCommand holdCommand = JsonCommand.fromJsonElement(savingsId, holdJson, this.fromApiJsonHelper);
final CommandProcessingResult holdResult = this.savingsAccountWritePlatformService.holdAmount(savingsId, holdCommand);
pendingEdit.setHoldTransactionId(holdResult.getResourceId());
pendingEdit.setHoldAmount(holdNeeded);
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("SUBMITEDIT") //
.entityName("SAVINGSACCOUNT") //
.resourceGetUrl("/savingsaccounts/" + savingsId) //
.resourceId(savingsId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder().withEntityId(savingsId).build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long savingsId) {
final AppUser checker = this.context.authenticatedUser();
final SavingsAccountPendingEdit pendingEdit = this.pendingEditRepository
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
final SavingsAccount account = this.savingAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsId);
final SavingsAccountFacilityConfig currentConfig = account.getFacilityConfig();
final Set<String> newlyEnabledFacilityTypes = pendingEdit.isFacilityConfigSubmitted()
? computeNewlyEnabled(pendingEdit, currentConfig)
: Set.of();
// 1) Release the hold placed at submission time.
if (pendingEdit.getHoldTransactionId() != null) {
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
}
// 2) Post the real charge(s) for newly-enabled facilities, today.
if (!newlyEnabledFacilityTypes.isEmpty()) {
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
}
// 3) Apply the facility-config toggles to the real row.
if (pendingEdit.isFacilityConfigSubmitted()) {
SavingsAccountFacilityConfig config = account.getFacilityConfig();
if (config == null) {
config = new SavingsAccountFacilityConfig(account);
account.setFacilityConfig(config);
}
config.updateFrom(buildFacilityConfigJson(pendingEdit));
this.facilityConfigRepository.saveAndFlush(config);
}
// 4) Apply the nominee change to the real table.
if (pendingEdit.isNomineeSubmitted()) {
applyNomineeChange(account, pendingEdit);
}
this.savingAccountRepositoryWrapper.saveAndFlush(account);
pendingEdit.markApproved(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, false);
return new CommandProcessingResultBuilder().withEntityId(savingsId).build();
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long savingsId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final SavingsAccountPendingEdit pendingEdit = this.pendingEditRepository
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
if (pendingEdit.getHoldTransactionId() != null) {
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
}
pendingEdit.markRejected(checker, rejectReason);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, true);
return new CommandProcessingResultBuilder().withEntityId(savingsId).build();
}
@Override
public SavingsAccountPendingEditData retrievePendingEdit(final Long savingsId) {
return this.pendingEditRepository.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.map(this::toData).orElse(null);
}
@Override
public java.util.Collection<SavingsAccountPendingEditData> retrieveAllPendingEdits() {
return this.pendingEditRepository.findByStatus(SavingsAccountPendingEdit.STATUS_PENDING).stream().map(this::toData).toList();
}
// ── helpers ──────────────────────────────────────────────────────────────
private void applyNomineeChange(final SavingsAccount account, final SavingsAccountPendingEdit pendingEdit) {
final Long clientId = account.clientId();
final String operation = pendingEdit.getNomineeOperation();
if (SavingsAccountPendingEdit.NOMINEE_OP_DELETE.equals(operation)) {
this.clientNomineeWritePlatformService.deleteNominee(clientId, pendingEdit.getNomineeId());
return;
}
final String accountType;
if (account.depositAccountType().isFixedDeposit()) {
accountType = "FD";
} else if (account.depositAccountType().isRecurringDeposit()) {
accountType = "RD";
} else {
accountType = "SB";
}
final JsonObject nomineeJson = new JsonObject();
nomineeJson.addProperty("locale", "en");
nomineeJson.addProperty("dateFormat", "yyyy-MM-dd");
nomineeJson.addProperty("accountNo", account.getAccountNumber());
nomineeJson.addProperty("accountType", accountType);
if (pendingEdit.getNomineeClientId() != null) {
nomineeJson.addProperty("nomineeClientId", pendingEdit.getNomineeClientId());
}
if (pendingEdit.getRelationshipCvId() != null) {
nomineeJson.addProperty("relationshipId", pendingEdit.getRelationshipCvId());
}
if (pendingEdit.getPercentageShare() != null) {
nomineeJson.addProperty("percentageShare", pendingEdit.getPercentageShare());
}
if (pendingEdit.getNomineeIsActive() != null) {
nomineeJson.addProperty("isActive", pendingEdit.getNomineeIsActive());
}
if (pendingEdit.getNomineeIsMinor() != null) {
nomineeJson.addProperty("nomineeIsMinor", pendingEdit.getNomineeIsMinor());
}
if (pendingEdit.getGuardianClientId() != null) {
nomineeJson.addProperty("guardianClientId", pendingEdit.getGuardianClientId());
}
if (pendingEdit.getGuardianRelationshipCvId() != null) {
nomineeJson.addProperty("guardianRelationshipId", pendingEdit.getGuardianRelationshipCvId());
}
if (pendingEdit.getEffectiveDate() != null) {
nomineeJson.addProperty("effectiveDate", pendingEdit.getEffectiveDate().toString());
}
if (pendingEdit.getNomineeRemarks() != null) {
nomineeJson.addProperty("remarks", pendingEdit.getNomineeRemarks());
}
final JsonCommand nomineeCommand = JsonCommand.fromJsonElement(clientId, nomineeJson, this.fromApiJsonHelper);
if (SavingsAccountPendingEdit.NOMINEE_OP_ADD.equals(operation)) {
this.clientNomineeWritePlatformService.addNominee(clientId, nomineeCommand);
} else if (SavingsAccountPendingEdit.NOMINEE_OP_UPDATE.equals(operation)) {
this.clientNomineeWritePlatformService.updateNominee(clientId, pendingEdit.getNomineeId(), nomineeCommand);
}
}
private JsonObject buildFacilityConfigJson(final SavingsAccountPendingEdit p) {
final JsonObject fc = new JsonObject();
putIfPresent(fc, "atmFacility", p.getAtmFacility());
putIfPresent(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfPresent(fc, "upiFacility", p.getUpiFacility());
putIfPresent(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfPresent(fc, "chequeBook", p.getChequeBook());
putIfPresent(fc, "impsFacility", p.getImpsFacility());
putIfPresent(fc, "smsAlerts", p.getSmsAlerts());
putIfPresent(fc, "bbpsFacility", p.getBbpsFacility());
putIfPresent(fc, "insuranceCover", p.getInsuranceCover());
putIfPresent(fc, "bepgFacility", p.getBepgFacility());
putIfPresent(fc, "neftFacility", p.getNeftFacility());
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
return fc;
}
private void putIfPresent(final JsonObject json, final String field, final Boolean value) {
if (value != null) {
json.addProperty(field, value);
}
}
private Set<String> computeNewlyEnabled(final SavingsAccountPendingEdit p, final SavingsAccountFacilityConfig current) {
final Set<String> result = new HashSet<>();
addIfNewlyEnabled(result, "atmFacility", p.getAtmFacility(), current == null ? null : current.getAtmFacility());
addIfNewlyEnabled(result, "atmWithdrawalFacility", p.getAtmWithdrawalFacility(), current == null ? null : current.getAtmWithdrawalFacility());
addIfNewlyEnabled(result, "upiFacility", p.getUpiFacility(), current == null ? null : current.getUpiFacility());
addIfNewlyEnabled(result, "posTransactionsFacility", p.getPosTransactionsFacility(), current == null ? null : current.getPosTransactionsFacility());
addIfNewlyEnabled(result, "chequeBook", p.getChequeBook(), current == null ? null : current.getChequeBook());
addIfNewlyEnabled(result, "impsFacility", p.getImpsFacility(), current == null ? null : current.getImpsFacility());
addIfNewlyEnabled(result, "smsAlerts", p.getSmsAlerts(), current == null ? null : current.getSmsAlerts());
addIfNewlyEnabled(result, "bbpsFacility", p.getBbpsFacility(), current == null ? null : current.getBbpsFacility());
addIfNewlyEnabled(result, "insuranceCover", p.getInsuranceCover(), current == null ? null : current.getInsuranceCover());
addIfNewlyEnabled(result, "bepgFacility", p.getBepgFacility(), current == null ? null : current.getBepgFacility());
addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility());
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility());
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current == null ? null : current.getInterBankTotalCapFacility());
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current == null ? null : current.getWithdrawalFacility());
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current == null ? null : current.getDepositFacilityKyc());
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard());
return result;
}
private void addIfNewlyEnabled(final Set<String> result, final String field, final Boolean proposed, final Boolean current) {
if (Boolean.TRUE.equals(proposed) && !Boolean.TRUE.equals(current)) {
result.add(FACILITY_TYPES.get(field));
}
}
/** Reads a single submitted field out of the incoming JSON, tracks whether it enables a previously-off facility. */
private void populateFacilityField(final JsonObject fc, final String field, final Boolean currentValue,
final java.util.function.Consumer<Boolean> setter, final Set<String> newlyEnabledCollector) {
if (!fc.has(field) || fc.get(field).isJsonNull()) {
return;
}
final boolean proposed = fc.get(field).getAsBoolean();
setter.accept(proposed);
if (proposed && !Boolean.TRUE.equals(currentValue)) {
newlyEnabledCollector.add(FACILITY_TYPES.get(field));
}
}
private void updateAuditEntry(final SavingsAccountPendingEdit pendingEdit, final AppUser checker, final boolean rejected) {
Optional<CommandSource> existing = Optional.empty();
if (pendingEdit.getCommandSourceId() != null) {
existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
}
final CommandSource auditEntry = existing.orElseGet(() -> CommandSource.builder() //
.actionName(rejected ? "REJECTEDIT" : "APPROVEEDIT") //
.entityName("SAVINGSACCOUNT") //
.resourceGetUrl("/savingsaccounts/" + pendingEdit.getSavingsAccount().getId()) //
.resourceId(pendingEdit.getSavingsAccount().getId()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build());
if (rejected) {
auditEntry.markAsRejected(checker);
} else {
auditEntry.markAsChecked(checker);
}
this.commandSourceRepository.saveAndFlush(auditEntry);
}
private SavingsAccountPendingEditData toData(final SavingsAccountPendingEdit p) {
final SavingsAccountPendingEditData data = new SavingsAccountPendingEditData();
data.setId(p.getId());
data.setSavingsAccountId(p.getSavingsAccount().getId());
data.setAccountNo(p.getSavingsAccount().getAccountNumber());
data.setMakerId(p.getMaker().getId());
data.setMakerUsername(p.getMaker().getUsername());
if (p.getChecker() != null) {
data.setCheckerId(p.getChecker().getId());
data.setCheckerUsername(p.getChecker().getUsername());
}
data.setCreatedOn(p.getCreatedOn());
data.setCheckedOn(p.getCheckedOn());
data.setStatus(p.getStatus());
data.setRejectReason(p.getRejectReason());
data.setHoldAmount(p.getHoldAmount());
data.setFacilityConfigSubmitted(p.isFacilityConfigSubmitted());
data.setNomineeSubmitted(p.isNomineeSubmitted());
if (p.getSavingsAccount() != null) {
data.setDepositAccountType(p.getSavingsAccount().depositAccountType().name());
}
if (p.isFacilityConfigSubmitted()) {
final Map<String, Object> fc = new LinkedHashMap<>();
putIfNotNull(fc, "atmFacility", p.getAtmFacility());
putIfNotNull(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfNotNull(fc, "upiFacility", p.getUpiFacility());
putIfNotNull(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfNotNull(fc, "chequeBook", p.getChequeBook());
putIfNotNull(fc, "impsFacility", p.getImpsFacility());
putIfNotNull(fc, "smsAlerts", p.getSmsAlerts());
putIfNotNull(fc, "bbpsFacility", p.getBbpsFacility());
putIfNotNull(fc, "insuranceCover", p.getInsuranceCover());
putIfNotNull(fc, "bepgFacility", p.getBepgFacility());
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
data.setProposedFacilityConfig(fc);
}
if (p.isNomineeSubmitted()) {
final Map<String, Object> nom = new LinkedHashMap<>();
putIfNotNull(nom, "operation", p.getNomineeOperation());
putIfNotNull(nom, "nomineeId", p.getNomineeId());
putIfNotNull(nom, "nomineeClientId", p.getNomineeClientId());
putIfNotNull(nom, "relationshipId", p.getRelationshipCvId());
putIfNotNull(nom, "percentageShare", p.getPercentageShare());
putIfNotNull(nom, "isActive", p.getNomineeIsActive());
putIfNotNull(nom, "nomineeIsMinor", p.getNomineeIsMinor());
putIfNotNull(nom, "guardianClientId", p.getGuardianClientId());
putIfNotNull(nom, "guardianRelationshipId", p.getGuardianRelationshipCvId());
putIfNotNull(nom, "effectiveDate", p.getEffectiveDate());
putIfNotNull(nom, "remarks", p.getNomineeRemarks());
data.setProposedNominee(nom);
}
return data;
}
private void putIfNotNull(final Map<String, Object> map, final String key, final Object value) {
if (value != null) {
map.put(key, value);
}
}
}
@@ -152,6 +152,16 @@ import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatform
import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEditRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.CurrentAccountEditWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateEffectiveRepository;
@@ -468,6 +478,35 @@ public class SavingsConfiguration {
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
}
@Bean
@ConditionalOnMissingBean(SavingsAccountEditWritePlatformService.class)
public SavingsAccountEditWritePlatformService savingsAccountEditWritePlatformService(PlatformSecurityContext context,
SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper,
org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEditRepository pendingEditRepository,
SavingsAccountFacilityConfigRepository facilityConfigRepository, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
SavingsAccountWritePlatformService savingsAccountWritePlatformService,
org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService clientNomineeWritePlatformService,
org.apache.fineract.commands.domain.CommandSourceRepository commandSourceRepository, FromJsonHelper fromApiJsonHelper) {
return new SavingsAccountEditWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
pendingEditRepository, facilityConfigRepository, facilityChargeAutoApplyService, savingsAccountWritePlatformService,
clientNomineeWritePlatformService, commandSourceRepository, fromApiJsonHelper);
}
@Bean
@ConditionalOnMissingBean(CurrentAccountEditWritePlatformService.class)
public CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService(PlatformSecurityContext context,
CurrentAccountRepository currentAccountRepository, CurrentAccountPendingEditRepository pendingEditRepository,
CurrentAccountFacilityConfigRepository facilityConfigRepository,
org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository,
CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService,
CurrentAccountWritePlatformService currentAccountWritePlatformService,
ClientNomineeWritePlatformService clientNomineeWritePlatformService, CommandSourceRepository commandSourceRepository,
FromJsonHelper fromApiJsonHelper) {
return new CurrentAccountEditWritePlatformServiceJpaRepositoryImpl(context, currentAccountRepository, pendingEditRepository,
facilityConfigRepository, facilityChargeMappingRepository, currentAccountTransactionWritePlatformService,
currentAccountWritePlatformService, clientNomineeWritePlatformService, commandSourceRepository, fromApiJsonHelper);
}
@Bean
@ConditionalOnMissingBean(SavingsApplicationProcessWritePlatformService.class)
public SavingsApplicationProcessWritePlatformService savingsApplicationProcessWritePlatformService(PlatformSecurityContext context,
@@ -368,4 +368,6 @@
<include file="parts/0338_add_soft_delete_to_tellers.xml" relativeToChangelogFile="true"/>
<include file="parts/0339_add_share_linking_enabled_to_current_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0340_add_terminate_true_to_savings_and_current_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0341_add_current_account_pending_edit_table.xml" relativeToChangelogFile="true"/>
<include file="parts/0342_add_current_account_edit_approval_permissions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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">
<!--
Holds a pending edit request for an ACTIVE current account's Nominee + Digital & Account
Features, mirroring m_savings_account_pending_edit's maker-checker pattern: presence of a row
here with status=PENDING (enforced via the unique pending_lock_key) is itself the lock, and
nothing is applied to the real m_current_account_facility_config / m_client_nominee rows until
a checker approves (see CurrentAccountEditWritePlatformServiceJpaRepositoryImpl). One row per
request; only one nominee change (add/update/delete) can be proposed per request, so its fields
are kept as flat typed columns on this same table rather than a satellite table. Also carries a
full point-in-time "wide snapshot" of every business column on m_current_account at submission
time, for audit/UI display purposes only.
-->
<changeSet author="fineract" id="0341-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_current_account_pending_edit" />
</not>
</preConditions>
<createTable tableName="m_current_account_pending_edit">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="current_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_caoe_current_account_id" references="m_current_account(id)" />
</column>
<column name="createdby_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_caoe_maker_id" references="m_appuser(id)" />
</column>
<column name="lastmodifiedby_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_checker_id" references="m_appuser(id)" />
</column>
<column name="created_date" type="DATETIME">
<constraints nullable="false" />
</column>
<column name="lastmodified_date" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="reject_reason" type="VARCHAR(500)" />
<column name="pending_lock_key" type="VARCHAR(40)">
<constraints nullable="true" unique="true" uniqueConstraintName="caoe_unq_pending_lock_key" />
</column>
<column name="command_source_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_command_source_id" references="m_portfolio_command_source(id)" />
</column>
<!-- Hold placed at submission time for the total charge of newly-enabled facilities; released
(no charge) on reject, released-then-converted-to-a-posted-charge on approve. -->
<column name="hold_transaction_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_hold_txn_id" references="m_current_account_transaction(id)" />
</column>
<column name="hold_amount" type="DECIMAL(19,6)" />
<!-- Digital & Account Features - same 18 columns/names as m_current_account_facility_config.
NULL = maker did not touch this toggle; facility_config_submitted tells approve()/UI
whether this block was submitted at all. -->
<column name="facility_config_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="atm_facility" type="BOOLEAN" />
<column name="atm_withdrawal_facility" type="BOOLEAN" />
<column name="upi_facility" type="BOOLEAN" />
<column name="pos_transactions_facility" type="BOOLEAN" />
<column name="cheque_book" type="BOOLEAN" />
<column name="imps_facility" type="BOOLEAN" />
<column name="sms_alerts" type="BOOLEAN" />
<column name="bbps_facility" type="BOOLEAN" />
<column name="insurance_cover" type="BOOLEAN" />
<column name="bepg_facility" type="BOOLEAN" />
<column name="neft_facility" type="BOOLEAN" />
<column name="rtgs_facility" type="BOOLEAN" />
<column name="inter_bank_total_cap_facility" type="BOOLEAN" />
<column name="withdrawal_facility" type="BOOLEAN" />
<column name="deposit_facility_kyc" type="BOOLEAN" />
<column name="virtual_debit_card" type="BOOLEAN" />
<column name="internet_banking" type="BOOLEAN" />
<column name="mobile_banking" type="BOOLEAN" />
<!-- Nominee change - one per request (add / update / delete), same fields as m_client_nominee. -->
<column name="nominee_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="nominee_operation" type="VARCHAR(10)" />
<column name="nominee_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_nominee_id" references="m_client_nominee(id)" />
</column>
<column name="nominee_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_nominee_client_id" references="m_client(id)" />
</column>
<column name="relationship_cv_id" type="BIGINT" />
<column name="percentage_share" type="DECIMAL(5,2)" />
<column name="nominee_is_active" type="BOOLEAN" />
<column name="nominee_is_minor" type="BOOLEAN" />
<column name="guardian_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_guardian_client_id" references="m_client(id)" />
</column>
<column name="guardian_relationship_cv_id" type="BIGINT" />
<column name="effective_date" type="DATE" />
<column name="nominee_remarks" type="VARCHAR(500)" />
<!-- Wide point-in-time snapshot of every business column on m_current_account at submission
time (audit/UI display only - approve()/reject() logic reads the live account, not this).
All nullable, no unique constraints, no defaults - this is a frozen copy. -->
<column name="account_no" type="VARCHAR(20)" />
<column name="external_id" type="VARCHAR(100)" />
<column name="client_id" type="BIGINT" />
<column name="product_id" type="BIGINT" />
<column name="office_id" type="BIGINT" />
<column name="staff_id" type="BIGINT" />
<column name="status_enum" type="INTEGER" />
<column name="sub_status_enum" type="INTEGER" />
<column name="account_type_enum" type="INTEGER" />
<column name="submittedon_date" type="DATE" />
<column name="approvedon_date" type="DATE" />
<column name="activatedon_date" type="DATE" />
<column name="rejectedon_date" type="DATE" />
<column name="withdrawnon_date" type="DATE" />
<column name="closedon_date" type="DATE" />
<column name="submittedon_userid" type="BIGINT" />
<column name="approvedon_userid" type="BIGINT" />
<column name="activatedon_userid" type="BIGINT" />
<column name="rejectedon_userid" type="BIGINT" />
<column name="withdrawnon_userid" type="BIGINT" />
<column name="closedon_userid" type="BIGINT" />
<column name="currency_code" type="VARCHAR(3)" />
<column name="currency_digits" type="INTEGER" />
<column name="currency_multiplesof" type="INTEGER" />
<column name="nominal_annual_interest_rate" type="DECIMAL(19,6)" />
<column name="interest_compounding_period_enum" type="INTEGER" />
<column name="interest_posting_period_enum" type="INTEGER" />
<column name="interest_calculation_type_enum" type="INTEGER" />
<column name="interest_calculation_days_in_year_type_enum" type="INTEGER" />
<column name="min_balance_for_interest_calculation" type="DECIMAL(19,6)" />
<column name="start_interest_calculation_date" type="DATE" />
<column name="accrued_till_date" type="DATE" />
<column name="last_interest_calculation_date" type="DATE" />
<column name="last_closed_business_date" type="DATE" />
<column name="withhold_tax" type="BOOLEAN" />
<column name="min_required_opening_balance" type="DECIMAL(19,6)" />
<column name="lockin_period_frequency" type="INTEGER" />
<column name="lockin_period_frequency_enum" type="INTEGER" />
<column name="lockedin_until_date_derived" type="DATE" />
<column name="enforce_min_required_balance" type="BOOLEAN" />
<column name="min_required_balance" type="DECIMAL(19,6)" />
<column name="allow_overdraft" type="BOOLEAN" />
<column name="overdraft_limit" type="DECIMAL(19,6)" />
<column name="nominal_annual_interest_rate_overdraft" type="DECIMAL(19,6)" />
<column name="min_overdraft_for_interest_calculation" type="DECIMAL(19,6)" />
<column name="is_lien_allowed" type="BOOLEAN" />
<column name="max_allowed_lien_limit" type="DECIMAL(19,6)" />
<column name="on_hold_funds_derived" type="DECIMAL(19,6)" />
<column name="account_balance_derived" type="DECIMAL(19,6)" />
<column name="total_deposits_derived" type="DECIMAL(19,6)" />
<column name="total_withdrawals_derived" type="DECIMAL(19,6)" />
<column name="total_interest_earned_derived" type="DECIMAL(19,6)" />
<column name="total_interest_posted_derived" type="DECIMAL(19,6)" />
<column name="total_overdraft_interest_derived" type="DECIMAL(19,6)" />
<column name="total_withhold_tax_derived" type="DECIMAL(19,6)" />
<column name="total_savings_amount_on_hold" type="DECIMAL(19,6)" />
<column name="withdrawal_fee_for_transfer" type="BOOLEAN" />
<column name="reason_for_block" type="VARCHAR(500)" />
<column name="msme" type="BOOLEAN" />
<column name="auto_debit_facility" type="BOOLEAN" />
<column name="balance_swap_option" type="BOOLEAN" />
<column name="establishment_date" type="DATE" />
<column name="start_operation_date" type="DATE" />
<column name="is_non_individual_client" type="BOOLEAN" />
<column name="signatory_rights" type="VARCHAR(100)" />
<column name="cbs_account_type" type="VARCHAR(100)" />
<column name="customer_segment" type="VARCHAR(100)" />
<column name="account_status" type="VARCHAR(50)" />
<column name="mode_of_operation" type="VARCHAR(50)" />
<column name="special_category" type="VARCHAR(100)" />
<column name="introducer_client_id" type="BIGINT" />
<column name="introducer_name" type="VARCHAR(200)" />
<column name="introducer_cif_number" type="VARCHAR(50)" />
<column name="nominee_data" type="TEXT" />
<column name="interest_calculate_selector" type="INTEGER" />
<column name="average_period" type="VARCHAR(20)" />
<column name="auto_post" type="BOOLEAN" />
<column name="frequency_fix_day" type="INTEGER" />
<column name="enable_lockin_period" type="BOOLEAN" />
<column name="balance_frequency" type="VARCHAR(50)" />
<column name="avg_balance_calc_period" type="VARCHAR(10)" />
<column name="account_closed_zero_balance_in_day_closing" type="BOOLEAN" />
<column name="valid_for_period" type="VARCHAR(10)" />
<column name="od_account_closed_zero_balance_in_day_closing" type="BOOLEAN" />
<column name="is_linking_enable" type="BOOLEAN" />
<column name="terminate_true" type="BOOLEAN" />
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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">
<!--
Permissions gating the Nominee + Digital & Account Features maker-checker edit flow for
current accounts. SUBMITEDIT lets a maker propose the edit (this is the "Edit" action shown
on the account view); APPROVEEDIT/REJECTEDIT let a checker resolve it. The _CHECKER permission
follows the "<ACTION>_<ENTITY>_CHECKER" convention so the pending request also surfaces in the
generic Checker Inbox for limited checkers. Grouping matches the rest of CURRENTACCOUNT's
permissions in 0225_add_current_account_support.xml ("portfolio").
-->
<changeSet author="fineract" id="0342-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CURRENTACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="SUBMITEDIT_CURRENTACCOUNT" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="SUBMITEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="0342-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="APPROVEEDIT_CURRENTACCOUNT" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="APPROVEEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="0342-3">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_CURRENTACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="REJECTEDIT_CURRENTACCOUNT" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="REJECTEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="0342-4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="APPROVEEDIT_CURRENTACCOUNT_CHECKER" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="APPROVEEDIT_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -932,6 +932,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
}
private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) {
if (this.nominalAnnualInterestRateOverdraft == null) {
return BigDecimal.ZERO;
}
return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc);
}
@@ -0,0 +1,213 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Holds a pending edit request for an ACTIVE savings account's Nominee + Digital & Account Features.
* Presence of a row here with {@code status = PENDING} (enforced via the unique
* {@link #pendingLockKey}) is itself the lock - nothing is applied to the real
* {@code m_savings_account_facility_config} / {@code m_client_nominee} rows until a checker approves
* (see SavingsAccountEditWritePlatformServiceImpl). Only one nominee change (add/update/delete) can be
* proposed per request, so its fields are kept as flat typed columns on this same table.
*/
@Entity
@Table(name = "m_savings_account_pending_edit")
@Getter
@Setter
public class SavingsAccountPendingEdit extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String NOMINEE_OP_ADD = "ADD";
public static final String NOMINEE_OP_UPDATE = "UPDATE";
public static final String NOMINEE_OP_DELETE = "DELETE";
@ManyToOne
@JoinColumn(name = "savings_account_id", nullable = false)
private SavingsAccount savingsAccount;
@ManyToOne
@JoinColumn(name = "createdby_id", nullable = false)
private AppUser maker;
@ManyToOne
@JoinColumn(name = "lastmodifiedby_id")
private AppUser checker;
@Column(name = "created_date", nullable = false)
private LocalDateTime createdOn;
@Column(name = "lastmodified_date")
private LocalDateTime checkedOn;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
/** "SAVINGS_ACCOUNT_EDIT:<savingsAccountId>" while PENDING, NULL otherwise - the real DB-level lock. */
@Column(name = "pending_lock_key", length = 40)
private String pendingLockKey;
/** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */
@Column(name = "command_source_id")
private Long commandSourceId;
@Column(name = "hold_transaction_id")
private Long holdTransactionId;
@Column(name = "hold_amount", precision = 19, scale = 6)
private BigDecimal holdAmount;
@Column(name = "facility_config_submitted", nullable = false)
private boolean facilityConfigSubmitted = false;
@Column(name = "atm_facility")
private Boolean atmFacility;
@Column(name = "atm_withdrawal_facility")
private Boolean atmWithdrawalFacility;
@Column(name = "upi_facility")
private Boolean upiFacility;
@Column(name = "pos_transactions_facility")
private Boolean posTransactionsFacility;
@Column(name = "cheque_book")
private Boolean chequeBook;
@Column(name = "imps_facility")
private Boolean impsFacility;
@Column(name = "sms_alerts")
private Boolean smsAlerts;
@Column(name = "bbps_facility")
private Boolean bbpsFacility;
@Column(name = "insurance_cover")
private Boolean insuranceCover;
@Column(name = "bepg_facility")
private Boolean bepgFacility;
@Column(name = "neft_facility")
private Boolean neftFacility;
@Column(name = "rtgs_facility")
private Boolean rtgsFacility;
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc;
@Column(name = "virtual_debit_card")
private Boolean virtualDebitCard;
@Column(name = "nominee_submitted", nullable = false)
private boolean nomineeSubmitted = false;
@Column(name = "nominee_operation", length = 10)
private String nomineeOperation;
@Column(name = "nominee_id")
private Long nomineeId;
@Column(name = "nominee_client_id")
private Long nomineeClientId;
@Column(name = "relationship_cv_id")
private Long relationshipCvId;
@Column(name = "percentage_share", precision = 5, scale = 2)
private BigDecimal percentageShare;
@Column(name = "nominee_is_active")
private Boolean nomineeIsActive;
@Column(name = "nominee_is_minor")
private Boolean nomineeIsMinor;
@Column(name = "guardian_client_id")
private Long guardianClientId;
@Column(name = "guardian_relationship_cv_id")
private Long guardianRelationshipCvId;
@Column(name = "effective_date")
private LocalDate effectiveDate;
@Column(name = "nominee_remarks", length = 500)
private String nomineeRemarks;
protected SavingsAccountPendingEdit() {}
public SavingsAccountPendingEdit(final SavingsAccount savingsAccount, final AppUser maker) {
this.savingsAccount = savingsAccount;
this.maker = maker;
this.createdOn = LocalDateTime.now();
this.status = STATUS_PENDING;
this.pendingLockKey = lockKeyFor(savingsAccount.getId());
}
public static String lockKeyFor(final Long savingsAccountId) {
return "SAVINGS_ACCOUNT_EDIT:" + savingsAccountId;
}
public boolean isPending() {
return STATUS_PENDING.equals(this.status);
}
public void markApproved(final AppUser checker) {
this.status = STATUS_APPROVED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.pendingLockKey = null;
}
public void markRejected(final AppUser checker, final String reason) {
this.status = STATUS_REJECTED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,32 @@
/**
* 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.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SavingsAccountPendingEditRepository extends JpaRepository<SavingsAccountPendingEdit, Long> {
Optional<SavingsAccountPendingEdit> findBySavingsAccountIdAndStatus(Long savingsAccountId, String status);
List<SavingsAccountPendingEdit> findByStatus(String status);
}
@@ -0,0 +1,37 @@
/**
* 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.exception;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown at edit-submission time when the account's available balance cannot cover the charge for
* the digital/account feature(s) the maker is requesting to enable.
*/
public class SavingsAccountEditInsufficientBalanceException extends AbstractPlatformDomainRuleException {
public SavingsAccountEditInsufficientBalanceException(final Long savingsId, final BigDecimal availableBalance,
final BigDecimal requiredAmount) {
super("error.msg.savingsaccount.edit.insufficient.balance",
"Account balance is insufficient for the requested feature(s). Available balance: " + availableBalance
+ ", required: " + requiredAmount,
savingsId, availableBalance, requiredAmount);
}
}
@@ -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
*
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsAccountEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public SavingsAccountEditPendingApprovalException(final Long savingsId) {
super("error.msg.savingsaccount.edit.pending.approval",
"Savings account with identifier " + savingsId + " already has a Nominee/Digital-Feature edit pending approval",
savingsId);
}
}
@@ -0,0 +1,31 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsAccountEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public SavingsAccountEditSelfApprovalNotAllowedException(final Long savingsId) {
super("error.msg.savingsaccount.edit.self.approval.not.allowed",
"The maker of the pending Nominee/Digital-Feature edit for savings account " + savingsId
+ " cannot also approve or reject it",
savingsId);
}
}
@@ -0,0 +1,29 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsAccountNoPendingEditException extends AbstractPlatformDomainRuleException {
public SavingsAccountNoPendingEditException(final Long savingsId) {
super("error.msg.savingsaccount.no.pending.edit",
"Savings account with identifier " + savingsId + " has no Nominee/Digital-Feature edit awaiting approval", savingsId);
}
}
@@ -88,6 +88,81 @@ public class FacilityChargeAutoApplyService {
}
}
/**
* Sum of the mapped charge amounts for the given facility types - used to size the hold placed at
* edit-submission time (see SavingsAccountEditWritePlatformServiceImpl) before any charge is actually applied.
*/
public BigDecimal totalChargeAmountFor(final java.util.Set<String> facilityTypes) {
BigDecimal total = BigDecimal.ZERO;
for (final String facilityType : facilityTypes) {
final java.util.Optional<FacilityChargeMapping> mapping = mappingRepository.findByFacilityType(facilityType);
if (mapping.isPresent() && mapping.get().getCharge() != null) {
total = total.add(mapping.get().getCharge().getAmount());
}
}
return total;
}
/**
* Adds a fresh {@link SavingsAccountCharge} for each of the given (newly re-enabled) facility types, so approving
* a Nominee/Digital-Feature edit always charges again for the transition - unlike
* {@link #applyFacilityChargesOnActivation(SavingsAccount)}'s {@code applyIfEnabled}, this deliberately has NO
* "already applied" dedup-by-name guard: a facility that was enabled at activation, disabled, then re-enabled via
* an approved edit must be charged again, even though a (now-paid, now-irrelevant) charge object with the same
* name already sits in {@code account.charges()} from the earlier cycle.
*/
public List<SavingsAccountCharge> applyChargesForFacilities(final SavingsAccount account, final java.util.Set<String> facilityTypes) {
final List<SavingsAccountCharge> chargesToApply = new ArrayList<>();
for (final String facilityType : facilityTypes) {
mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> {
final Charge charge = mapping.getCharge();
if (charge == null) {
return;
}
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(),
null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
chargesToApply.add(accountCharge);
});
}
if (!chargesToApply.isEmpty()) {
savingsAccountChargeRepository.saveAll(chargesToApply);
}
return chargesToApply;
}
/**
* Pays exactly the given charges (typically the just-added result of {@link #applyChargesForFacilities}) as of
* {@code transactionDate}. Unlike {@link #collectActivationCharges(SavingsAccount)} - which posts at the
* account's activation date and sweeps every unpaid activation-type charge - this is scoped to a specific set of
* charges and a caller-supplied date, for posting a Nominee/Digital-Feature edit's charges on the day it's
* approved rather than backdating them to activation.
*/
public List<SavingsAccountTransaction> collectCharges(final SavingsAccount account, final List<SavingsAccountCharge> charges,
final LocalDate transactionDate) {
final List<SavingsAccountTransaction> transactions = new ArrayList<>();
for (final SavingsAccountCharge charge : charges) {
if (charge.isPaid() || charge.isWaived()) continue;
final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue;
final Money amountMoney = Money.of(account.getCurrency(), amount);
final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, transactionDate, false, null);
if (txn != null) {
transactions.add(txn);
log.info("Collected facility charge '{}' ₹{} from savings account id={}", charge.getCharge().getName(), amount,
account.getId());
}
}
if (!transactions.isEmpty()) {
savingsAccountTransactionRepository.saveAll(transactions);
account.recalculateBalancesAfterFacilityCharges(transactionDate);
}
return transactions;
}
/**
* Collects all unpaid SavingsActivation charges on the account.
* Called after facility charges are saved and flushed, so amounts are properly initialized.
@@ -0,0 +1,36 @@
/**
* 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;
import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData;
public interface SavingsAccountEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long savingsId, JsonCommand command);
CommandProcessingResult approveEdit(Long savingsId);
CommandProcessingResult rejectEdit(Long savingsId, String rejectReason);
SavingsAccountPendingEditData retrievePendingEdit(Long savingsId);
java.util.Collection<SavingsAccountPendingEditData> retrieveAllPendingEdits();
}
@@ -51,4 +51,6 @@
<include file="parts/2027_add_pending_edit_satellite_tables.xml" relativeToChangelogFile="true" />
<include file="parts/2028_update_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2029_remove_proposed_payload_json.xml" relativeToChangelogFile="true" />
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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">
<!--
Holds a pending edit request for an ACTIVE savings account's Nominee + Digital & Account
Features, mirroring the m_savings_product_temp maker-checker pattern: presence of a row here
with status=PENDING (enforced via the unique pending_lock_key) is itself the lock, and nothing
is applied to the real m_savings_account_facility_config / m_client_nominee rows until a
checker approves (see SavingsAccountEditWritePlatformServiceImpl). One row per request; only
one nominee change (add/update/delete) can be proposed per request, so its fields are kept as
flat typed columns on this same table rather than a satellite table.
-->
<changeSet author="fineract" id="2030-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_savings_account_pending_edit" />
</not>
</preConditions>
<createTable tableName="m_savings_account_pending_edit">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_saoe_savings_account_id" references="m_savings_account(id)" />
</column>
<column name="createdby_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_saoe_maker_id" references="m_appuser(id)" />
</column>
<column name="lastmodifiedby_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_checker_id" references="m_appuser(id)" />
</column>
<column name="created_date" type="DATETIME">
<constraints nullable="false" />
</column>
<column name="lastmodified_date" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="reject_reason" type="VARCHAR(500)" />
<column name="pending_lock_key" type="VARCHAR(40)">
<constraints nullable="true" unique="true" uniqueConstraintName="saoe_unq_pending_lock_key" />
</column>
<column name="command_source_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_command_source_id" references="m_portfolio_command_source(id)" />
</column>
<!-- Hold placed at submission time for the total charge of newly-enabled facilities; released
(no charge) on reject, released-then-converted-to-a-posted-charge on approve. -->
<column name="hold_transaction_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_hold_txn_id" references="m_savings_account_transaction(id)" />
</column>
<column name="hold_amount" type="DECIMAL(19,6)" />
<!-- Digital & Account Features - same 16 columns/names as m_savings_account_facility_config.
NULL = maker did not touch this toggle; facility_config_submitted tells approve()/UI
whether this block was submitted at all. -->
<column name="facility_config_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="atm_facility" type="BOOLEAN" />
<column name="atm_withdrawal_facility" type="BOOLEAN" />
<column name="upi_facility" type="BOOLEAN" />
<column name="pos_transactions_facility" type="BOOLEAN" />
<column name="cheque_book" type="BOOLEAN" />
<column name="imps_facility" type="BOOLEAN" />
<column name="sms_alerts" type="BOOLEAN" />
<column name="bbps_facility" type="BOOLEAN" />
<column name="insurance_cover" type="BOOLEAN" />
<column name="bepg_facility" type="BOOLEAN" />
<column name="neft_facility" type="BOOLEAN" />
<column name="rtgs_facility" type="BOOLEAN" />
<column name="inter_bank_total_cap_facility" type="BOOLEAN" />
<column name="withdrawal_facility" type="BOOLEAN" />
<column name="deposit_facility_kyc" type="BOOLEAN" />
<column name="virtual_debit_card" type="BOOLEAN" />
<!-- Nominee change - one per request (add / update / delete), same fields as m_client_nominee. -->
<column name="nominee_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="nominee_operation" type="VARCHAR(10)" />
<column name="nominee_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_nominee_id" references="m_client_nominee(id)" />
</column>
<column name="nominee_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_nominee_client_id" references="m_client(id)" />
</column>
<column name="relationship_cv_id" type="BIGINT" />
<column name="percentage_share" type="DECIMAL(5,2)" />
<column name="nominee_is_active" type="BOOLEAN" />
<column name="nominee_is_minor" type="BOOLEAN" />
<column name="guardian_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_guardian_client_id" references="m_client(id)" />
</column>
<column name="guardian_relationship_cv_id" type="BIGINT" />
<column name="effective_date" type="DATE" />
<column name="nominee_remarks" type="VARCHAR(500)" />
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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">
<!--
Permissions gating the Nominee + Digital & Account Features maker-checker edit flow for
savings accounts. SUBMITEDIT lets a maker propose the edit (this is the "Edit" action shown
on the account view); APPROVEEDIT/REJECTEDIT let a checker resolve it. The _CHECKER permission
follows the "<ACTION>_<ENTITY>_CHECKER" convention (see 2022_add_product_checker_permissions.xml)
so the pending request also surfaces in the generic Checker Inbox for limited checkers.
-->
<changeSet author="fineract" id="2031-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_SAVINGSACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="SUBMITEDIT_SAVINGSACCOUNT" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="SUBMITEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2031-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="APPROVEEDIT_SAVINGSACCOUNT" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="APPROVEEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2031-3">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_SAVINGSACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="REJECTEDIT_SAVINGSACCOUNT" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="REJECTEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2031-4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="APPROVEEDIT_SAVINGSACCOUNT_CHECKER" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="APPROVEEDIT_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>