diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/currentaccount/data/CurrentAccountPendingEditData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/currentaccount/data/CurrentAccountPendingEditData.java new file mode 100644 index 000000000..ebdeb4f43 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/currentaccount/data/CurrentAccountPendingEditData.java @@ -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 proposedFacilityConfig; + private Map proposedNominee; + + public CurrentAccountPendingEditData() {} +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountPendingEditData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountPendingEditData.java new file mode 100644 index 000000000..b9c36d373 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountPendingEditData.java @@ -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 proposedFacilityConfig; + private Map proposedNominee; + private String depositAccountType; + + public SavingsAccountPendingEditData() {} +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java index 50ba4164f..c91a00cac 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java @@ -99,4 +99,31 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom { + + Optional findByCurrentAccountId(Long currentAccountId); +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEdit.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEdit.java new file mode 100644 index 000000000..3b418d7d2 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEdit.java @@ -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 { + + 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:" 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; + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEditRepository.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEditRepository.java new file mode 100644 index 000000000..a1756d3f8 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountPendingEditRepository.java @@ -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 { + + Optional findByCurrentAccountIdAndStatus(Long currentAccountId, String status); + + List findByStatus(String status); +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditInsufficientBalanceException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditInsufficientBalanceException.java new file mode 100644 index 000000000..53a08df7b --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditInsufficientBalanceException.java @@ -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); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditPendingApprovalException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditPendingApprovalException.java new file mode 100644 index 000000000..eae3edd73 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditPendingApprovalException.java @@ -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); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditSelfApprovalNotAllowedException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..0b6d06053 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountEditSelfApprovalNotAllowedException.java @@ -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); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountNoPendingEditException.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountNoPendingEditException.java new file mode 100644 index 000000000..9e342d9be --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/exception/CurrentAccountNoPendingEditException.java @@ -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); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountEditWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountEditWritePlatformService.java new file mode 100644 index 000000000..281377a01 --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountEditWritePlatformService.java @@ -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 retrieveAllPendingEdits(); +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java index 2556aaf46..e8364e913 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountWritePlatformService.java @@ -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 newlyEnabledFacilityTypes); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java index 46ec0bace..7675c811f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java @@ -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", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java index a0310f85a..c73fa3095 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java @@ -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 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 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); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java index 16b8d270c..e2e480614 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java @@ -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 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 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 }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..d48f970d3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -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. + * + *

+ * 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 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 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 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 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 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 computeNewlyEnabled(final CurrentAccountPendingEdit p, final CurrentAccountFacilityConfig current) { + final Set 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 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 setter, final Set 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 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 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 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 map, final String key, final Object value) { + if (value != null) { + map.put(key, value); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java index 42752b631..571ff708c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java @@ -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 retrieveFacilityConfig(Long accountId) { + final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?"; + List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { + final Map 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> 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 buildSummary(Map row) { Map 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index cf9f24db0..ae4195d2c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -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 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 charges, + java.math.BigDecimal runningBalance, final LocalDate transactionDate, + final java.util.List 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 newlyEnabledFacilityTypes) { + if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { + return; + } + final CurrentAccount account = findAccount(currentAccountId); + final java.util.List 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 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..19c590261 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -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. + * + *

+ * 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 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 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 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 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 computeNewlyEnabled(final SavingsAccountPendingEdit p, final SavingsAccountFacilityConfig current) { + final Set 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 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 setter, final Set 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 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 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 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 map, final String key, final Object value) { + if (value != null) { + map.put(key, value); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index b5fa180bc..cb8915828 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -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, diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 9d1ccba1a..e431174cf 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -368,4 +368,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_add_current_account_pending_edit_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_add_current_account_pending_edit_table.xml new file mode 100644 index 000000000..9c8e57efd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_add_current_account_pending_edit_table.xml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_add_current_account_edit_approval_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_add_current_account_edit_approval_permissions.xml new file mode 100644 index 000000000..53d231abe --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_add_current_account_edit_approval_permissions.xml @@ -0,0 +1,85 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CURRENTACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_CURRENTACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT_CHECKER' + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index a3d54240d..ad1434f36 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -932,6 +932,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) { + if (this.nominalAnnualInterestRateOverdraft == null) { + return BigDecimal.ZERO; + } return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc); } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEdit.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEdit.java new file mode 100644 index 000000000..fdbc016c7 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEdit.java @@ -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 { + + 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:" 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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEditRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEditRepository.java new file mode 100644 index 000000000..d6e509421 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountPendingEditRepository.java @@ -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 { + + Optional findBySavingsAccountIdAndStatus(Long savingsAccountId, String status); + + List findByStatus(String status); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditInsufficientBalanceException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditInsufficientBalanceException.java new file mode 100644 index 000000000..6e3c052c2 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditInsufficientBalanceException.java @@ -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); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditPendingApprovalException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditPendingApprovalException.java new file mode 100644 index 000000000..c672f3479 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditPendingApprovalException.java @@ -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); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditSelfApprovalNotAllowedException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..ff68905d8 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountEditSelfApprovalNotAllowedException.java @@ -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); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountNoPendingEditException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountNoPendingEditException.java new file mode 100644 index 000000000..7350eb382 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/SavingsAccountNoPendingEditException.java @@ -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); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java index e159576cf..556fc755a 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java @@ -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 facilityTypes) { + BigDecimal total = BigDecimal.ZERO; + for (final String facilityType : facilityTypes) { + final java.util.Optional 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 applyChargesForFacilities(final SavingsAccount account, final java.util.Set facilityTypes) { + final List 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 collectCharges(final SavingsAccount account, final List charges, + final LocalDate transactionDate) { + final List 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. diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformService.java new file mode 100644 index 000000000..e92d97ae2 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformService.java @@ -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 retrieveAllPendingEdits(); +} diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml index c717feeaa..ec16ad31d 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml @@ -51,4 +51,6 @@ + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2030_add_savings_account_pending_edit_table.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2030_add_savings_account_pending_edit_table.xml new file mode 100644 index 000000000..4d29a1778 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2030_add_savings_account_pending_edit_table.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2031_add_savings_account_edit_approval_permissions.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2031_add_savings_account_edit_approval_permissions.xml new file mode 100644 index 000000000..b867caad7 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2031_add_savings_account_edit_approval_permissions.xml @@ -0,0 +1,84 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_SAVINGSACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_SAVINGSACCOUNT' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT_CHECKER' + + + + + + + + + +